fix(search): report partially missing FTS indexes in query results (#3448)

This commit is contained in:
articultur 2026-10-03 13:36:27 +07:00 • committed by GitHub
parent f99dde8aa3
commit 668fac7635
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 418 additions and 38 deletions

View file

@ -4044,6 +4044,7 @@ export const buildFtsQueryCypher = (
* @param query - Search query string
* @param limit - Maximum results
* @param conjunctive - If true, all terms must match (AND); if false, any term matches (OR)
* @param missingIndex - Preserve the empty-result default, or propagate missing indexes for diagnostics
* @returns Array of { node properties, score }
*/
export const queryFTS = async (
@ -4052,6 +4053,7 @@ export const queryFTS = async (
query: string,
limit: number = 20,
conjunctive: boolean = false,
missingIndex: 'empty' | 'throw' = 'empty',
): Promise<
Array<{ nodeId: string; name: string; filePath: string; score: number; [key: string]: any }>
> => {
@ -4082,7 +4084,7 @@ export const queryFTS = async (
// NEW-6 — this used to be a bare `.includes('does not exist')` check
// that could not tell the two apart).
const message = e instanceof Error ? e.message : String(e);
if (classifyFtsQueryError(message) === 'missing-index') {
if (missingIndex === 'empty' && classifyFtsQueryError(message) === 'missing-index') {
return [];
}
throw e;

View file

@ -47,6 +47,8 @@ export interface FTSSearchResponse {
* which only does so when every table failed).
*/
nonBenignErrors?: string[];
/** Configured table.index names that could not be queried because their index is missing. */
missingIndexes?: string[];
}
/**
@ -142,6 +144,7 @@ export const searchFTSFromLbug = async (
const resultsByIndex: any[][] = [];
let queriesSucceeded = 0;
const nonBenignErrors: string[] = [];
const missingIndexes: string[] = [];
const ftsExtension = getExtensionCapabilities().find((c) => c.name === 'fts');
if (ftsExtension && !ftsExtension.loaded) {
@ -171,24 +174,28 @@ export const searchFTSFromLbug = async (
if (outcome.rows) {
queriesSucceeded++;
resultsByIndex.push(outcome.rows);
} else if (!outcome.benign) {
} else if (outcome.benign) {
missingIndexes.push(`${table}.${indexName}`);
} else {
nonBenignErrors.push(redactPaths(outcome.message ?? 'Unknown FTS query error'));
}
}
} else {
// Use core lbug adapter (CLI / pipeline context) — also sequential for safety.
// tri-review Residual-1: `queryFTS` itself only swallows a genuinely-missing
// index (via the SAME classifyFtsQueryError this module re-exports); a
// missing-table or real query error rethrows here — track it the same way
// the MCP pool path does instead of a bare `catch {}` that dropped it.
// Opt into missing-index propagation so absent indexes cannot masquerade
// as successful zero-match queries. Keep core/pool classification identical.
for (const { table, indexName } of FTS_INDEXES) {
try {
const result = await queryFTS(table, indexName, searchQuery, limit, false);
const result = await queryFTS(table, indexName, searchQuery, limit, false, 'throw');
queriesSucceeded++;
resultsByIndex.push(result);
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
nonBenignErrors.push(redactPaths(message));
if (classifyFtsQueryError(message) === 'missing-index') {
missingIndexes.push(`${table}.${indexName}`);
} else {
nonBenignErrors.push(redactPaths(message));
}
}
}
}
@ -234,5 +241,6 @@ export const searchFTSFromLbug = async (
})),
ftsAvailable,
...(nonBenignErrors.length > 0 && { nonBenignErrors }),
...(missingIndexes.length > 0 && { missingIndexes }),
};
};

View file

@ -110,19 +110,21 @@ export const ftsDegradedWarning = (
};
/**
* Warning for when the FTS extension is loaded and indexes exist, but every
* configured table's query failed for a real, non-benign reason (timeout,
* connection reset, native fault) — as opposed to `ftsDegradedWarning`'s
* missing-index case. `--repair-fts` will not fix a query/connection error,
* so this deliberately does NOT suggest it: reusing the missing-index
* message here would reproduce, for this cause, the exact misleading
* "run --repair-fts" guidance #2767 itself was about (tri-review NEW-1).
* Warning when no FTS query succeeded and at least one failed for a real,
* non-benign reason (timeout, connection reset, native fault). `--repair-fts`
* will not fix those errors. If indexes are also missing, the caller composes
* their repair guidance separately; do not deny that additional failure cause.
*/
export const ftsQueryFailedWarning = (context: FtsWarningContext): string =>
export const ftsQueryFailedWarning = (
context: FtsWarningContext,
hasMissingIndexes = false,
): string =>
'FTS keyword search failed — every configured index query returned an error' +
(context.lastErrorRedacted ? ` (${context.lastErrorRedacted})` : '') +
'; results do not include keyword matches. This is not a missing-index ' +
'condition — see server logs for details.' +
'; results do not include keyword matches. ' +
(hasMissingIndexes
? 'See server logs for query error details.'
: 'This is not a missing-index condition — see server logs for details.') +
` (resolved: ${formatResolvedSuffix(context)})`;
// Stemmers shipped by the LadybugDB FTS extension. Mirrors the lowercase token

View file

@ -3172,6 +3172,7 @@ export class LocalBackend {
// regardless of whether OTHER tables succeeded — previously a real error
// on N-1 of N tables while one succeeded left zero diagnostic trail.
const ftsQueryErrors = bm25SearchResult?.nonBenignErrors;
const ftsMissingIndexes = bm25SearchResult?.missingIndexes;
if (ftsQueryErrors) {
// tri-review NEW-5: these strings are already classified non-benign by
// classifyFtsQueryError — do NOT route them through logQueryError,
@ -3661,13 +3662,15 @@ export class LocalBackend {
branch: repo.branch,
indexedAt: this.lastObservedPoolState.get(repo.lbugPath)?.indexedAt ?? repo.indexedAt,
};
// tri-review NEW-1: every table failing for a REAL error (timeout,
// connection reset) is not a missing-index condition — `ftsDegradedWarning`'s
// "run --repair-fts" headline won't fix it. Route to a dedicated message
// instead of burying the real cause as a trailing suffix on bad advice.
// Real errors (timeout, connection reset) need their own diagnosis.
// When some indexes are also missing, preserve both causes and append
// their repair guidance below even though no FTS query succeeded.
warnings.push(
ftsQueryErrors
? ftsQueryFailedWarning({ ...warningContext, lastErrorRedacted: ftsQueryErrors[0] })
? ftsQueryFailedWarning(
{ ...warningContext, lastErrorRedacted: ftsQueryErrors[0] },
!!ftsMissingIndexes?.length,
)
: ftsDegradedWarning(warningContext, ftsDisabledReason),
);
} else if (ftsQueryErrors) {
@ -3680,6 +3683,12 @@ export class LocalBackend {
`FTS keyword search partially failed — ${ftsQueryErrors.length} of the configured indexes hit a query error and were skipped; results may be missing matches from those node types (see server logs).`,
);
}
if (ftsMissingIndexes?.length && (ftsUsed || ftsQueryErrors)) {
warnings.push(
`FTS keyword search is incomplete: missing configured indexes (${ftsMissingIndexes.join(', ')}). ` +
'Results may be missing matches from those node types. Run `gitnexus analyze --repair-fts`.',
);
}
// #2331: a CJK query against a server process resolving
// GITNEXUS_FTS_CJK_SEGMENTATION to 'none' silently misses sub-phrase
// matches with no other signal — this is the only place an agent driving
@ -3811,7 +3820,7 @@ export class LocalBackend {
// #2767: a partial FTS failure (some tables ok, one or more real errors)
// is as much a "results may be incomplete" signal as enrichmentDegraded —
// flag it the same way rather than only via the warning string.
const ftsPartial = ftsUsed && !!ftsQueryErrors;
const ftsPartial = ftsUsed && (!!ftsQueryErrors || !!ftsMissingIndexes?.length);
return {
processes,
@ -3832,7 +3841,12 @@ export class LocalBackend {
query: string,
limit: number,
disabledReason?: FtsDisabledReason,
): Promise<{ results: any[]; ftsUsed: boolean; nonBenignErrors?: string[] }> {
): Promise<{
results: any[];
ftsUsed: boolean;
nonBenignErrors?: string[];
missingIndexes?: string[];
}> {
if (disabledReason) return { results: [], ftsUsed: false };
let searchFTSFromLbug;
try {
@ -3866,6 +3880,7 @@ export class LocalBackend {
const bm25Results = ftsResponse?.results ?? [];
const ftsUsed = ftsResponse?.ftsAvailable ?? false;
const nonBenignErrors = ftsResponse?.nonBenignErrors;
const missingIndexes = ftsResponse?.missingIndexes;
const results: any[] = [];
@ -3939,7 +3954,12 @@ export class LocalBackend {
}
}
return { results, ftsUsed, ...(nonBenignErrors && { nonBenignErrors }) };
return {
results,
ftsUsed,
...(nonBenignErrors && { nonBenignErrors }),
...(missingIndexes && { missingIndexes }),
};
}
/**

View file

@ -1,4 +1,5 @@
import type { FTSIndexDef } from '../helpers/test-indexed-db.js';
import { FTS_INDEXES } from '../../src/core/search/fts-schema.js';
export const LOCAL_BACKEND_SEED_DATA = [
// Files
@ -60,9 +61,8 @@ export const LOCAL_BACKEND_SEED_DATA = [
CREATE (c)-[:CodeRelation {type: 'HAS_METHOD', confidence: 1.0, reason: 'class-method', step: 0}]->(m)`,
];
export const LOCAL_BACKEND_FTS_INDEXES: FTSIndexDef[] = [
{ table: 'Function', indexName: 'function_fts', columns: ['name', 'content', 'description'] },
{ table: 'Class', indexName: 'class_fts', columns: ['name', 'content', 'description'] },
{ table: 'Method', indexName: 'method_fts', columns: ['name', 'content', 'description'] },
{ table: 'File', indexName: 'file_fts', columns: ['name', 'content'] },
];
// Healthy-backend fixtures must mirror the complete configured search index set.
// The old four-table subset is now correctly reported as partial FTS coverage.
export const LOCAL_BACKEND_FTS_INDEXES: FTSIndexDef[] = FTS_INDEXES.map(
({ table, indexName, properties }) => ({ table, indexName, columns: [...properties] }),
);

View file

@ -32,6 +32,7 @@ const REQUIRE_FTS = process.env.GITNEXUS_REQUIRE_FTS === '1';
type QueryResult = {
error?: unknown;
warning?: string;
partial?: boolean;
definitions?: Array<{ id: string }>;
process_symbols?: Array<{ id: string }>;
};
@ -40,7 +41,8 @@ const matchedIds = (r: QueryResult): string[] =>
[...(r.process_symbols ?? []), ...(r.definitions ?? [])].map((s) => s.id);
const ftsMissing = (r: QueryResult): boolean =>
typeof r.warning === 'string' && /FTS indexes missing/i.test(r.warning);
typeof r.warning === 'string' &&
/FTS indexes missing|missing configured indexes/i.test(r.warning);
/**
* Poll the SAME warm `LocalBackend` until it stops reporting FTS-missing, or
@ -90,14 +92,14 @@ describe('warm MCP session observes an in-place --repair-fts rebuild (#2767)', (
await tmpHandle.cleanup();
});
it(
'a warm session transitions from FTS-unavailable to FTS-available without restarting, after an out-of-band --repair-fts',
it.each(['all', 'Function'] as const)(
'a warm session repairs missing %s indexes without restarting',
{ timeout: 60_000 },
async (ctx) => {
async (missing, ctx) => {
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
const { createSearchFTSIndexes } = await import('../../src/core/search/fts-indexes.js');
// ── Step 1: build the index WITHOUT FTS (analyzed before repair) ────
// ── Step 1: build an index with all or just one FTS index missing ──
await adapter.initLbug(lbugPath);
const ftsAvailable = await adapter.loadFTSExtension(undefined, {
@ -118,6 +120,10 @@ describe('warm MCP session observes an in-place --repair-fts rebuild (#2767)', (
await adapter.executeQuery(
`CREATE (n:Function {id: 'func:login', name: 'login', filePath: 'src/auth.ts', startLine: 1, endLine: 3, content: 'function login() { return true; }'})`,
);
if (missing === 'Function') {
await createSearchFTSIndexes();
await adapter.dropFTSIndex('Function', 'function_fts');
}
await adapter.flushWAL();
await adapter.closeLbug();
@ -129,7 +135,10 @@ describe('warm MCP session observes an in-place --repair-fts rebuild (#2767)', (
stats: { files: 1, nodes: 1 },
capabilities: {
graph: { provider: 'ladybugdb', status: 'available' },
fts: { provider: 'ladybugdb-fts', status: 'unavailable' },
fts: {
provider: 'ladybugdb-fts',
status: missing === 'all' ? 'unavailable' : 'available',
},
vectorSearch: { provider: 'exact-scan', status: 'unavailable', exactScanLimit: 0 },
},
};
@ -142,6 +151,10 @@ describe('warm MCP session observes an in-place --repair-fts rebuild (#2767)', (
const before = await backend.callTool('query', { query: 'login' });
expect(before.error).toBeUndefined();
expect(ftsMissing(before)).toBe(true);
if (missing === 'Function') {
expect(before.partial).toBe(true);
expect(before.warning).toContain('Function.function_fts');
}
// ── Step 3: out-of-band --repair-fts (separate writable session) ────
// Same production functions the repair-fts branch of runFullAnalysis
@ -163,6 +176,7 @@ describe('warm MCP session observes an in-place --repair-fts rebuild (#2767)', (
const after = await waitForFtsRecognized(backend, 'login');
expect(after.error).toBeUndefined();
expect(ftsMissing(after)).toBe(false);
expect(after.partial).toBeUndefined();
expect(matchedIds(after)).toContain('func:login');
},
);

View file

@ -647,3 +647,125 @@ describe('BM25 search', () => {
});
});
});
describe('FTS index completeness', () => {
beforeEach(async () => {
resetExtensionState();
mockExecuteParameterized.mockReset();
const { queryFTS } = await import('../../src/core/lbug/lbug-adapter.js');
vi.mocked(queryFTS).mockReset();
});
const missing = (table: string, indexName: string) =>
new Error(`Binder exception: Table ${table} doesn't have an index with name ${indexName}.`);
for (const pooled of [false, true]) {
const mode = pooled ? 'pool' : 'core';
const repo = pooled ? 'completeness-test' : undefined;
async function useOutcome(outcome: (table: string, indexName: string) => Promise<any[]>) {
const { queryFTS } = await import('../../src/core/lbug/lbug-adapter.js');
vi.mocked(queryFTS).mockImplementation((table, indexName) => outcome(table, indexName));
mockExecuteParameterized.mockImplementation(async (_repo: string, cypher: string) => {
const match = cypher.match(/QUERY_FTS_INDEX\('([^']+)', '([^']+)'/);
return outcome(match![1], match![2]);
});
}
it(`${mode}: distinguishes healthy zero matches from missing indexes`, async () => {
await useOutcome(async () => []);
const result = await searchFTSFromLbug('no matches', 5, repo);
expect(result).toEqual({ results: [], ftsAvailable: true });
});
it(`${mode}: reports one missing index while retaining successful matches`, async () => {
await useOutcome(async (table, indexName) => {
if (table === 'Function') throw missing(table, indexName);
if (table !== 'File') return [];
return pooled
? [{ node: { filePath: 'src/auth.ts', id: 'file:auth' }, score: 3 }]
: [{ filePath: 'src/auth.ts', nodeId: 'file:auth', score: 3 }];
});
const result = await searchFTSFromLbug('auth', 5, repo);
expect(result.ftsAvailable).toBe(true);
expect(result.results[0]).toMatchObject({ filePath: 'src/auth.ts', score: 3 });
expect(result.missingIndexes).toEqual(['Function.function_fts']);
expect(result.nonBenignErrors).toBeUndefined();
});
it(`${mode}: reports all missing indexes without claiming FTS is available`, async () => {
await useOutcome(async (table, indexName) => {
throw missing(table, indexName);
});
const result = await searchFTSFromLbug('auth', 5, repo);
expect(result.ftsAvailable).toBe(false);
expect(result.results).toEqual([]);
if (!pooled) {
const { queryFTS } = await import('../../src/core/lbug/lbug-adapter.js');
for (const { table, indexName } of FTS_INDEXES) {
expect(queryFTS).toHaveBeenCalledWith(table, indexName, 'auth', 5, false, 'throw');
}
}
expect(result.missingIndexes).toEqual(
FTS_INDEXES.map(({ table, indexName }) => `${table}.${indexName}`),
);
expect(result.nonBenignErrors).toBeUndefined();
});
it(`${mode}: preserves redacted real errors alongside missing-index diagnostics`, async () => {
await useOutcome(async (table, indexName) => {
if (table === 'Function') throw missing(table, indexName);
if (table === 'Class')
throw new Error('connection reset at /home/alice/private/index.lbug');
return [];
});
const result = await searchFTSFromLbug('auth', 5, repo);
expect(result.ftsAvailable).toBe(true);
expect(result.missingIndexes).toEqual(['Function.function_fts']);
expect(result.nonBenignErrors).toHaveLength(1);
expect(result.nonBenignErrors![0]).toContain('connection reset');
expect(JSON.stringify(result)).not.toContain('/home/alice');
});
it(`${mode}: retains both failure causes when no configured index query succeeds`, async () => {
await useOutcome(async (table, indexName) => {
if (table === 'Function') throw missing(table, indexName);
throw new Error('connection reset at /home/alice/private/index.lbug');
});
const result = await searchFTSFromLbug('auth', 5, repo);
expect(result.ftsAvailable).toBe(false);
expect(result.results).toEqual([]);
expect(result.missingIndexes).toEqual(['Function.function_fts']);
expect(result.nonBenignErrors).toEqual(
Array(FTS_INDEXES.length - 1).fill('connection reset at <path>'),
);
});
it(`${mode}: clears missing-index diagnostics after a repaired query`, async () => {
await useOutcome(async (table, indexName) => {
if (table === 'Function') throw missing(table, indexName);
return [];
});
expect((await searchFTSFromLbug('auth', 5, repo)).missingIndexes).toEqual([
'Function.function_fts',
]);
await useOutcome(async () => []);
expect(await searchFTSFromLbug('auth', 5, repo)).toEqual({ results: [], ftsAvailable: true });
});
it(`${mode}: explicit opt-out makes no queries and emits no missing-index diagnosis`, async () => {
await useOutcome(async (table, indexName) => {
throw missing(table, indexName);
});
expect(await searchFTSFromLbug('auth', 5, repo, 'disabled-by-flag')).toEqual({
results: [],
ftsAvailable: false,
});
const { queryFTS } = await import('../../src/core/lbug/lbug-adapter.js');
expect(queryFTS).not.toHaveBeenCalled();
expect(mockExecuteParameterized).not.toHaveBeenCalled();
});
}
});

View file

@ -42,6 +42,7 @@ vi.mock('../../src/storage/repo-manager.js', async (importOriginal) => {
});
import { LocalBackend } from '../../src/mcp/local/local-backend';
import { resetExtensionState } from '../../src/core/lbug/extension-loader.js';
// A backend whose hybrid search yields exactly one matched symbol, so the
// enrichment chunk loop runs and can be made to fail. `ftsUsed` is parameterized
@ -415,3 +416,100 @@ describe('query: degraded-enrichment signal', () => {
}
});
});
describe('query: partial missing FTS indexes', () => {
beforeEach(() => {
vi.clearAllMocks();
loadMetaMock.mockResolvedValue(null);
executeParameterizedMock.mockResolvedValue([]);
});
afterEach(() => vi.unstubAllEnvs());
it('returns successful symbols with a repair hint and partial flag when another index is missing', async () => {
const b = makeBackend(true) as any;
const resultWithMissing = await b.backend.bm25Search();
b.backend.bm25Search.mockResolvedValue({
...resultWithMissing,
missingIndexes: ['Function.function_fts'],
});
const result = await runQuery(b);
expect(result.definitions.map((d: any) => d.id)).toContain('func:x');
expect(result.partial).toBe(true);
expect(result.warning).toContain('Function.function_fts');
expect(result.warning).toContain('repair-fts');
});
it('composes missing indexes with real FTS errors and enrichment failures', async () => {
const b = makeBackend(true, ['connection reset']) as any;
const resultWithMissing = await b.backend.bm25Search();
b.backend.bm25Search.mockResolvedValue({
...resultWithMissing,
missingIndexes: ['Function.function_fts'],
});
executeParameterizedMock.mockImplementation(async (_repo: string, cypher: string) => {
if (cypher.includes('STEP_IN_PROCESS')) throw new Error('timed out');
return [];
});
const result = await runQuery(b);
expect(result.partial).toBe(true);
expect(result.warning).toContain('Function.function_fts');
expect(result.warning).toContain('FTS keyword search partially failed');
expect(result.warning).toContain('enrichment');
});
});
it('propagates missing-index diagnostics through the real bm25Search helper into query', async () => {
vi.clearAllMocks();
loadMetaMock.mockResolvedValue(null);
executeParameterizedMock.mockResolvedValue([]);
const search = await import('../../src/core/search/bm25-index.js');
const spy = vi.spyOn(search, 'searchFTSFromLbug').mockResolvedValue({
results: [],
ftsAvailable: true,
missingIndexes: ['Function.function_fts'],
});
try {
const b = makeBackend(true) as any;
b.backend.bm25Search = (LocalBackend.prototype as any).bm25Search;
const result = await runQuery(b);
expect(result.partial).toBe(true);
expect(result.warning).toContain('Function.function_fts');
} finally {
spy.mockRestore();
}
});
it('reports missing indexes and real errors when every FTS query fails through the search boundary', async () => {
vi.clearAllMocks();
resetExtensionState();
loadMetaMock.mockResolvedValue(null);
executeParameterizedMock.mockImplementation(async (_repo: string, cypher: string) => {
if (cypher.includes("QUERY_FTS_INDEX('Function'")) {
throw new Error(
"Binder exception: Table Function doesn't have an index with name function_fts.",
);
}
if (cypher.includes('QUERY_FTS_INDEX')) {
throw new Error('connection reset at /home/alice/private/index.lbug');
}
return [];
});
const b = makeBackend(false) as any;
b.backend.bm25Search = (LocalBackend.prototype as any).bm25Search;
const result = await runQuery(b);
expect(result.definitions).toEqual([]);
expect(result.warning).toContain('FTS keyword search failed');
expect(result.warning).toContain('connection reset at <path>');
expect(result.warning).toContain('Function.function_fts');
expect(result.warning).toContain('gitnexus analyze --repair-fts');
expect(result.warning).toContain('resolved: repo1');
expect(result.warning).not.toContain('not a missing-index');
expect(result.warning).not.toContain('partially failed');
expect(JSON.stringify(result)).not.toContain('/home/alice');
});

View file

@ -0,0 +1,114 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
const native = vi.hoisted(() => ({
error: undefined as string | undefined,
params: [] as unknown[],
}));
// Control the native statement result, while exercising the real adapter's
// connection lock, prepared-query path and missing-index error handling.
vi.mock('@ladybugdb/core', () => {
const emptyResult = {
getAll: async () => [],
close: async () => {},
isSuccess: () => true,
getErrorMessage: async () => '',
};
class Database {
async init() {}
async close() {}
}
class Connection {
async query() {
return emptyResult;
}
async prepare() {
return {
isSuccess: () => native.error === undefined,
getErrorMessage: async () => native.error ?? '',
};
}
async execute(_statement: unknown, params: unknown) {
native.params.push(params);
return emptyResult;
}
async close() {}
}
const mod = { Database, Connection };
return { ...mod, default: mod, lbug: mod };
});
import { closeLbug, queryFTS, withLbugDb } from '../../src/core/lbug/lbug-adapter.js';
const MISSING = "Binder exception: Table Function doesn't have an index with name function_fts.";
describe('queryFTS missing-index diagnostics', () => {
let dir: string;
let dbPath: string;
beforeEach(async () => {
native.error = undefined;
native.params = [];
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-test-'));
dbPath = path.join(dir, 'lbug');
await fs.writeFile(dbPath, '');
});
afterEach(async () => {
await closeLbug();
await fs.rm(dir, { recursive: true, force: true });
});
it('retains the existing empty-result default for a missing index', async () => {
await withLbugDb(
dbPath,
async () => {
native.error = MISSING;
expect(await queryFTS('Function', 'function_fts', 'auth')).toEqual([]);
},
{ readOnly: true, skipFts: true },
);
});
it('can propagate a missing index so search does not count it as a successful zero-match query', async () => {
await withLbugDb(
dbPath,
async () => {
native.error = MISSING;
await expect(
queryFTS('Function', 'function_fts', 'auth', 5, false, 'throw'),
).rejects.toThrow(MISSING);
},
{ readOnly: true, skipFts: true },
);
});
it('preserves prepared query binding and successful empty results in diagnostic mode', async () => {
await withLbugDb(
dbPath,
async () => {
const query = "auth' DELETE n";
expect(await queryFTS('Function', 'function_fts', query, 5, false, 'throw')).toEqual([]);
expect(native.params).toEqual([{ query }]);
},
{ readOnly: true, skipFts: true },
);
});
it('still propagates real failures in either missing-index mode', async () => {
await withLbugDb(
dbPath,
async () => {
native.error = 'Runtime exception: connection reset';
await expect(queryFTS('Function', 'function_fts', 'auth')).rejects.toThrow(
'connection reset',
);
await expect(
queryFTS('Function', 'function_fts', 'auth', 5, false, 'throw'),
).rejects.toThrow('connection reset');
},
{ readOnly: true, skipFts: true },
);
});
});