瀏覽代碼

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

Hypatia May 3 月之前
父節點
當前提交
f401528941

+ 4 - 17
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)
 }

+ 29 - 4
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<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[] {
   return filters.map((filter) => {
     if ('values' in filter) {

+ 13 - 0
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', () => {