fix(session-query): preflight SQLite bindings (round 6)

This commit is contained in:
Hypatia May
2026-07-17 10:29:49 +08:00
parent 220076e5e2
commit f401528941
3 changed files with 46 additions and 21 deletions

View File

@@ -40,6 +40,7 @@ import {
type NormalizedSessionRequest, type NormalizedSessionRequest,
FTS_HIGHLIGHT_END, FTS_HIGHLIGHT_END,
FTS_HIGHLIGHT_START, FTS_HIGHLIGHT_START,
assertPortableBindingCount,
buildEventWhere, buildEventWhere,
buildSessionWhere, buildSessionWhere,
makeSnippet, makeSnippet,
@@ -64,8 +65,7 @@ export const SESSION_QUERY_SQLITE_MAX_LIMIT = 100
/** Default maximum snippet length in Unicode code points. */ /** Default maximum snippet length in Unicode code points. */
export const SESSION_QUERY_SQLITE_SNIPPET_CHARS = 240 export const SESSION_QUERY_SQLITE_SNIPPET_CHARS = 240
// A serialized search tolerates one transient source change; repeated churn // One transient source change gets a retry; repeated churn fails rather than monopolizing the queue.
// fails instead of monopolizing the operation queue.
const STABLE_OBSERVATION_ATTEMPTS = 2 const STABLE_OBSERVATION_ATTEMPTS = 2
/** SQLite session-search configuration. */ /** SQLite session-search configuration. */
@@ -566,7 +566,7 @@ export class SessionSearchSqlite extends SessionSearchService {
request.limit + 1, request.limit + 1,
offset, offset,
] ]
assertPortableBindingCount(bindings) assertPortableBindingCount(bindings.length)
return this._requireDb().prepare(` return this._requireDb().prepare(`
${selected.sql}, ${selected.sql},
filtered AS ( filtered AS (
@@ -601,7 +601,7 @@ export class SessionSearchSqlite extends SessionSearchService {
request.limit + 1, request.limit + 1,
offset, offset,
] ]
assertPortableBindingCount(bindings) assertPortableBindingCount(bindings.length)
return this._requireDb().prepare(` return this._requireDb().prepare(`
${selected.sql} ${selected.sql}
SELECT * FROM matched SELECT * FROM matched
@@ -732,19 +732,6 @@ function selectedDocumentsParams(query: string, persistenceVisible: boolean): Ar
] ]
} }
// SQLite builds may raise this ceiling; supported modern versions share 32,766
// as the portable host-parameter limit.
const SQLITE_PORTABLE_VARIABLE_LIMIT = 32_766
function assertPortableBindingCount(bindings: readonly (string | number)[]): void {
if (bindings.length > SQLITE_PORTABLE_VARIABLE_LIMIT) {
throw new SessionQueryError(
`session-search request requires ${bindings.length} SQLite bindings; reduce filters to stay within the portable ${SQLITE_PORTABLE_VARIABLE_LIMIT}-variable limit`,
'SESSION_QUERY_INVALID_FILTER',
)
}
}
function observeLive(session: Session): ObservedSession { function observeLive(session: Session): ObservedSession {
return observeSession(session.header, session.events) return observeSession(session.header, session.events)
} }

View File

@@ -23,6 +23,22 @@ export const FTS_HIGHLIGHT_END = '\uFDD1'
/** Largest page size whose internal lookahead remains an exact SQLite integer binding. */ /** Largest page size whose internal lookahead remains an exact SQLite integer binding. */
export const SQLITE_MAX_PAGE_LIMIT = Number.MAX_SAFE_INTEGER - 1 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
/**
* Reject prospective SQLite binding growth beyond the portable ceiling.
* @param count - binding count at the current construction boundary.
*/
export function assertPortableBindingCount(count: number): void {
if (count > SQLITE_PORTABLE_VARIABLE_LIMIT) {
throw new SessionQueryError(
`session-search request exceeds SQLite's portable ${SQLITE_PORTABLE_VARIABLE_LIMIT}-variable limit; reduce filter values`,
'SESSION_QUERY_INVALID_FILTER',
)
}
}
/** Limit defaults needed to normalize a search request. */ /** Limit defaults needed to normalize a search request. */
export interface QueryLimits { export interface QueryLimits {
/** Page size used when the request omits one. */ /** Page size used when the request omits one. */
@@ -356,8 +372,7 @@ function addList(
clauses.push('0') clauses.push('0')
return return
} }
clauses.push(`${column} IN (${values.map(() => '?').join(', ')})`) clauses.push(`${column} IN (${appendListBindings(params, values)})`)
params.push(...values)
} }
function addNullableList( function addNullableList(
@@ -373,8 +388,7 @@ function addNullableList(
const concrete = values.filter((value): value is string => value !== null) const concrete = values.filter((value): value is string => value !== null)
const parts: string[] = [] const parts: string[] = []
if (concrete.length > 0) { if (concrete.length > 0) {
parts.push(`${column} IN (${concrete.map(() => '?').join(', ')})`) parts.push(`${column} IN (${appendListBindings(params, concrete)})`)
params.push(...concrete)
} }
if (values.includes(null)) parts.push(`${column} IS NULL`) if (values.includes(null)) parts.push(`${column} IS NULL`)
clauses.push(`(${parts.join(' OR ')})`) clauses.push(`(${parts.join(' OR ')})`)
@@ -387,15 +401,26 @@ function addRange(
range: { from?: number; to?: number }, range: { from?: number; to?: number },
): void { ): void {
if (range.from !== undefined) { if (range.from !== undefined) {
assertPortableBindingCount(params.length + 1)
clauses.push(`CAST(${column} AS INTEGER) >= ?`) clauses.push(`CAST(${column} AS INTEGER) >= ?`)
params.push(range.from) params.push(range.from)
} }
if (range.to !== undefined) { if (range.to !== undefined) {
assertPortableBindingCount(params.length + 1)
clauses.push(`CAST(${column} AS INTEGER) <= ?`) clauses.push(`CAST(${column} AS INTEGER) <= ?`)
params.push(range.to) params.push(range.to)
} }
} }
function appendListBindings(
params: Array<string | number>,
values: readonly (string | number)[],
): string {
assertPortableBindingCount(params.length + values.length)
for (const value of values) params.push(value)
return values.map(() => '?').join(', ')
}
function canonicalFilters(filters: readonly (SessionResultFilter | SessionEventMetadataFilter)[]): unknown[] { function canonicalFilters(filters: readonly (SessionResultFilter | SessionEventMetadataFilter)[]): unknown[] {
return filters.map((filter) => { return filters.map((filter) => {
if ('values' in filter) { if ('values' in filter) {

View File

@@ -445,6 +445,19 @@ describe('SQLite session search', () => {
], ],
})).rejects.toThrow(expectCode('SESSION_QUERY_INVALID_FILTER')) })).rejects.toThrow(expectCode('SESSION_QUERY_INVALID_FILTER'))
}) })
it('rejects one 125,000-value filter list with a typed error', async () => {
const ctx = await liveContext()
const ids = Array.from(
{ length: 125_000 },
(_, index) => SessionId(`oversized-binding-${index}`),
)
await expect(ctx.sessionSearch.searchSessions({
query: 'needle',
sessionFilters: [{ kind: 'id', values: ids }],
})).rejects.toThrow(expectCode('SESSION_QUERY_INVALID_FILTER'))
})
}) })
describe('SQLite reconciliation and source lifecycle', () => { describe('SQLite reconciliation and source lifecycle', () => {