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 changed files with 119 additions and 9 deletions

View File

@@ -11,6 +11,7 @@ import {
normalizeSessionRequest,
quoteFtsData,
requestFingerprint,
SQLITE_FTS5_OUTER_PREDICATE_LIMIT,
SQLITE_MAX_PAGE_LIMIT,
type NormalizedEventRequest,
type NormalizedSessionRequest,
@@ -111,24 +112,36 @@ describe('SQLite search request normalization', () => {
describe('SQLite search predicate compilation', () => {
it('compiles all logical-session clauses including empty and nullable values', () => {
expect(buildSessionWhere([])).toEqual({ sql: '', params: [] })
expect(buildSessionWhere([{ kind: 'id', values: [] }])).toEqual({ sql: '0', params: [] })
expect(buildSessionWhere([])).toEqual({ sql: '', params: [], predicateCount: 0 })
expect(buildSessionWhere([{ kind: 'id', values: [] }])).toEqual({
sql: '0',
params: [],
predicateCount: 1,
})
expect(buildSessionWhere([{ kind: 'id', values: [SessionId('a'), SessionId('b')] }])).toEqual({
sql: 'session_id IN (?, ?)',
params: [SessionId('a'), SessionId('b')],
predicateCount: 1,
})
expect(buildSessionWhere([{ kind: 'cwd', values: [] }])).toEqual({
sql: '0',
params: [],
predicateCount: 1,
})
expect(buildSessionWhere([{ kind: 'cwd', values: [] }])).toEqual({ sql: '0', params: [] })
expect(buildSessionWhere([{ kind: 'cwd', values: [null] }])).toEqual({
sql: '(cwd IS NULL)',
params: [],
predicateCount: 1,
})
expect(buildSessionWhere([{ kind: 'cwd', values: ['/a'] }])).toEqual({
sql: '(cwd IN (?))',
params: ['/a'],
predicateCount: 1,
})
expect(buildSessionWhere([{ kind: 'parent', values: [SessionId('p'), null] }])).toEqual({
sql: '(parent_session IN (?) OR parent_session IS NULL)',
params: [SessionId('p')],
predicateCount: 1,
})
expect(buildSessionWhere([
{ kind: 'created-at', from: 1, to: 2 },
@@ -138,8 +151,13 @@ describe('SQLite search predicate compilation', () => {
])).toEqual({
sql: 'CAST(created_at AS INTEGER) >= ? AND CAST(created_at AS INTEGER) <= ? AND 0 AND live = 1',
params: [1, 2],
predicateCount: 4,
})
expect(buildSessionWhere([{ kind: 'created-at' }])).toEqual({
sql: '',
params: [],
predicateCount: 0,
})
expect(buildSessionWhere([{ kind: 'created-at' }])).toEqual({ sql: '', params: [] })
})
it('compiles every event clause and empty lists', () => {
@@ -151,11 +169,25 @@ describe('SQLite search predicate compilation', () => {
])).toEqual({
sql: 'CAST(seq AS INTEGER) >= ? AND CAST(time AS INTEGER) <= ? AND type IN (?) AND surface IN (?, ?)',
params: [1, 9, 'user/message', 'current', 'log-only'],
predicateCount: 4,
})
expect(buildEventWhere([
{ kind: 'type', values: [] },
{ kind: 'surface', values: [] },
])).toEqual({ sql: '0 AND 0', params: [] })
])).toEqual({ sql: '0 AND 0', params: [], predicateCount: 2 })
})
it('rejects predicate builders above the supported FTS5 outer budget', () => {
const filters = Array.from(
{ length: SQLITE_FTS5_OUTER_PREDICATE_LIMIT },
() => ({ kind: 'id' as const, values: [SessionId('safe')] }),
)
expect(buildSessionWhere(filters).predicateCount).toBe(SQLITE_FTS5_OUTER_PREDICATE_LIMIT)
expect(() => buildSessionWhere([
...filters,
{ kind: 'id', values: [SessionId('over')] },
])).toThrow(expectCode('SESSION_QUERY_INVALID_FILTER'))
})
it('rejects runtime-unknown filter discriminants in both SQL builders', () => {

View File

@@ -210,6 +210,61 @@ describe('SQLite session search', () => {
})
})
it('searches at the supported FTS5 outer-predicate boundary in both scopes', async () => {
const ctx = await liveContext()
const session = ctx.sessions.create(SessionId('predicate-boundary'), {
seed: messageEvents('needle'),
meta: { cwd: '/work' },
})
const sessionFilters = Array.from(
{ length: 14 },
() => ({ kind: 'cwd' as const, values: ['/work', null] }),
)
const eventFilters = Array.from(
{ length: 13 },
() => ({ kind: 'type' as const, values: ['user/message' as const] }),
)
await expect(ctx.sessionSearch.searchSessions({ query: 'needle', sessionFilters }))
.resolves.toMatchObject({ items: [{ header: { id: session.id } }] })
await expect(ctx.sessionSearch.searchEvents({
sessionId: session.id,
query: 'needle',
filters: eventFilters,
})).resolves.toMatchObject({ items: [{ sessionId: session.id, seq: 0 }] })
})
it('rejects unsupported FTS5 outer-predicate counts with typed errors', async () => {
const ctx = await liveContext()
const session = ctx.sessions.create(SessionId('predicate-limit'), { seed: messageEvents('needle') })
const sessionFilters = Array.from(
{ length: 1_100 },
() => ({ kind: 'id' as const, values: [session.id] }),
)
const eventFilters = Array.from(
{ length: 1_100 },
() => ({ kind: 'type' as const, values: ['user/message' as const] }),
)
await expect(ctx.sessionSearch.searchSessions({ query: 'needle', sessionFilters }))
.rejects.toThrow(expectCode('SESSION_QUERY_INVALID_FILTER'))
await expect(ctx.sessionSearch.searchEvents({
sessionId: session.id,
query: 'needle',
filters: eventFilters,
})).rejects.toThrow(expectCode('SESSION_QUERY_INVALID_FILTER'))
await expect(ctx.sessionSearch.searchSessions({
query: 'needle',
sessionFilters: sessionFilters.slice(0, 7),
eventFilters: eventFilters.slice(0, 8),
})).rejects.toThrow(expectCode('SESSION_QUERY_INVALID_FILTER'))
await expect(ctx.sessionSearch.searchEvents({
sessionId: session.id,
query: 'needle',
filters: eventFilters.slice(0, 14),
})).rejects.toThrow(expectCode('SESSION_QUERY_INVALID_FILTER'))
})
it('uses literal phrase tokens, stable ties, and bounded Unicode snippets', async () => {
const ctx = await liveContext({ path: ':memory:', defaultLimit: 10, maxLimit: 10, snippetChars: 5 })
ctx.sessions.create(SessionId('a'), { seed: messageEvents('😀😀 alpha beta BRAID 😀😀', 10), meta: { createdAt: 1 } })