fix(session-query): guard FTS predicate planning (round 7)

This commit is contained in:
Hypatia May
2026-07-17 11:13:03 +08:00
parent f401528941
commit e8abfd6482
6 files changed
+119 -9

No files matched your search

@@ -40,6 +40,7 @@ import {
type NormalizedSessionRequest,
FTS_HIGHLIGHT_END,
FTS_HIGHLIGHT_START,
assertFts5OuterPredicateCount,
assertPortableBindingCount,
buildEventWhere,
buildSessionWhere,
@@ -558,6 +559,7 @@ export class SessionSearchSqlite extends SessionSearchService {
const selected = selectedDocumentsSql()
const sessionWhere = buildSessionWhere(request.sessionFilters)
const eventWhere = buildEventWhere(request.eventFilters)
assertFts5OuterPredicateCount(sessionWhere.predicateCount + eventWhere.predicateCount)
const where = [sessionWhere.sql, eventWhere.sql].filter(Boolean).join(' AND ')
const bindings = [
...selectedDocumentsParams(request.query, persistenceBinding.service !== undefined),
@@ -593,6 +595,7 @@ export class SessionSearchSqlite extends SessionSearchService {
): SearchRow[] {
const selected = selectedDocumentsSql()
const eventWhere = buildEventWhere(request.filters)
assertFts5OuterPredicateCount(1 + eventWhere.predicateCount)
const where = ['session_id = ?', eventWhere.sql].filter(Boolean).join(' AND ')
const bindings = [
...selectedDocumentsParams(request.query, persistenceBinding.service !== undefined),
@@ -26,6 +26,9 @@ export const SQLITE_MAX_PAGE_LIMIT = Number.MAX_SAFE_INTEGER - 1
/** Portable host-parameter ceiling shared by predicate and statement builders. */
export const SQLITE_PORTABLE_VARIABLE_LIMIT = 32_766
/** Supported outer-predicate budget that keeps SQLite FTS5 MATCH usable. */
export const SQLITE_FTS5_OUTER_PREDICATE_LIMIT = 14
/**
* Reject prospective SQLite binding growth beyond the portable ceiling.
* @param count - binding count at the current construction boundary.
@@ -39,6 +42,19 @@ export function assertPortableBindingCount(count: number): void {
}
}
/**
* Reject compiled outer predicates beyond the supported FTS5 planner budget.
* @param count - predicate count including fixed statement predicates.
*/
export function assertFts5OuterPredicateCount(count: number): void {
if (count > SQLITE_FTS5_OUTER_PREDICATE_LIMIT) {
throw new SessionQueryError(
`session-search request exceeds the supported SQLite FTS5 outer-predicate budget of ${SQLITE_FTS5_OUTER_PREDICATE_LIMIT}; reduce filters`,
'SESSION_QUERY_INVALID_FILTER',
)
}
}
/** Limit defaults needed to normalize a search request. */
export interface QueryLimits {
/** Page size used when the request omits one. */
@@ -71,6 +87,8 @@ export interface SqlWhere {
sql: string
/** Bindings in placeholder order. */
params: Array<string | number>
/** Number of compiled predicates in `sql`. */
predicateCount: number
}
/**
@@ -163,7 +181,8 @@ export function buildSessionWhere(filters: readonly SessionResultFilter[]): SqlW
unknownFilter(filter)
}
}
return { sql: clauses.join(' AND '), params }
assertFts5OuterPredicateCount(clauses.length)
return { sql: clauses.join(' AND '), params, predicateCount: clauses.length }
}
/**
@@ -192,7 +211,8 @@ export function buildEventWhere(filters: readonly SessionEventMetadataFilter[]):
unknownFilter(filter)
}
}
return { sql: clauses.join(' AND '), params }
assertFts5OuterPredicateCount(clauses.length)
return { sql: clauses.join(' AND '), params, predicateCount: clauses.length }
}
/**