From f401528941e39c4b96957a9d910af5ced7ab08de Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Fri, 17 Jul 2026 10:29:49 +0800 Subject: [PATCH] fix(session-query): preflight SQLite bindings (round 6) --- .../session-query-sqlite/src/index.ts | 21 +++--------- .../session-query-sqlite/src/query.ts | 33 ++++++++++++++++--- .../session-query-sqlite/tests/sqlite.spec.ts | 13 ++++++++ 3 files changed, 46 insertions(+), 21 deletions(-) diff --git a/packages/session-query/session-query-sqlite/src/index.ts b/packages/session-query/session-query-sqlite/src/index.ts index 4428d7ca00..05b5edace4 100644 --- a/packages/session-query/session-query-sqlite/src/index.ts +++ b/packages/session-query/session-query-sqlite/src/index.ts @@ -40,6 +40,7 @@ import { type NormalizedSessionRequest, FTS_HIGHLIGHT_END, FTS_HIGHLIGHT_START, + assertPortableBindingCount, buildEventWhere, buildSessionWhere, makeSnippet, @@ -64,8 +65,7 @@ export const SESSION_QUERY_SQLITE_MAX_LIMIT = 100 /** Default maximum snippet length in Unicode code points. */ export const SESSION_QUERY_SQLITE_SNIPPET_CHARS = 240 -// A serialized search tolerates one transient source change; repeated churn -// fails instead of monopolizing the operation queue. +// One transient source change gets a retry; repeated churn fails rather than monopolizing the queue. const STABLE_OBSERVATION_ATTEMPTS = 2 /** SQLite session-search configuration. */ @@ -566,7 +566,7 @@ export class SessionSearchSqlite extends SessionSearchService { request.limit + 1, offset, ] - assertPortableBindingCount(bindings) + assertPortableBindingCount(bindings.length) return this._requireDb().prepare(` ${selected.sql}, filtered AS ( @@ -601,7 +601,7 @@ export class SessionSearchSqlite extends SessionSearchService { request.limit + 1, offset, ] - assertPortableBindingCount(bindings) + assertPortableBindingCount(bindings.length) return this._requireDb().prepare(` ${selected.sql} 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 { return observeSession(session.header, session.events) } diff --git a/packages/session-query/session-query-sqlite/src/query.ts b/packages/session-query/session-query-sqlite/src/query.ts index 5eb3ed8380..409c20f028 100644 --- a/packages/session-query/session-query-sqlite/src/query.ts +++ b/packages/session-query/session-query-sqlite/src/query.ts @@ -23,6 +23,22 @@ export const FTS_HIGHLIGHT_END = '\uFDD1' /** Largest page size whose internal lookahead remains an exact SQLite integer binding. */ 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. */ export interface QueryLimits { /** Page size used when the request omits one. */ @@ -356,8 +372,7 @@ function addList( clauses.push('0') return } - clauses.push(`${column} IN (${values.map(() => '?').join(', ')})`) - params.push(...values) + clauses.push(`${column} IN (${appendListBindings(params, values)})`) } function addNullableList( @@ -373,8 +388,7 @@ function addNullableList( const concrete = values.filter((value): value is string => value !== null) const parts: string[] = [] if (concrete.length > 0) { - parts.push(`${column} IN (${concrete.map(() => '?').join(', ')})`) - params.push(...concrete) + parts.push(`${column} IN (${appendListBindings(params, concrete)})`) } if (values.includes(null)) parts.push(`${column} IS NULL`) clauses.push(`(${parts.join(' OR ')})`) @@ -387,15 +401,26 @@ function addRange( range: { from?: number; to?: number }, ): void { if (range.from !== undefined) { + assertPortableBindingCount(params.length + 1) clauses.push(`CAST(${column} AS INTEGER) >= ?`) params.push(range.from) } if (range.to !== undefined) { + assertPortableBindingCount(params.length + 1) clauses.push(`CAST(${column} AS INTEGER) <= ?`) params.push(range.to) } } +function appendListBindings( + params: Array, + 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[] { return filters.map((filter) => { if ('values' in filter) { diff --git a/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts b/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts index 4b875931c4..8db1df5666 100644 --- a/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts +++ b/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts @@ -445,6 +445,19 @@ describe('SQLite session search', () => { ], })).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', () => {