feat(session-query): add SQLite full-text search

This commit is contained in:
Hypatia May
2026-07-15 10:51:38 +08:00
parent e9f0c37745
commit ecf90ff382
38 changed files with 3181 additions and 120 deletions

View File

@@ -0,0 +1,179 @@
import { describe, expect, it } from 'vitest'
import { SessionId } from '@deepseek-ai/dsh-session'
import type { SessionQueryErrorCode } from '@deepseek-ai/dsh-session-query'
import {
buildEventWhere,
buildSessionWhere,
makeSnippet,
normalizeEventRequest,
normalizeSessionRequest,
quoteFtsData,
requestFingerprint,
type NormalizedEventRequest,
type NormalizedSessionRequest,
} from '../src/query.ts'
const limits = { defaultLimit: 2, maxLimit: 3 }
function expectCode(code: SessionQueryErrorCode): Error {
return expect.objectContaining({ code }) as Error
}
describe('SQLite search request normalization', () => {
it('normalizes both scopes, defaults arrays and limits, and preserves cursors', () => {
expect(normalizeSessionRequest({ query: ' alpha\n beta ' }, limits)).toEqual({
query: 'alpha beta',
sessionFilters: [],
eventFilters: [],
limit: 2,
})
expect(normalizeSessionRequest({
query: 'needle',
sessionFilters: [{ kind: 'availability', values: ['live'] }],
eventFilters: [{ kind: 'surface', values: ['current'] }],
limit: 3,
cursor: 'next',
}, limits)).toEqual({
query: 'needle',
sessionFilters: [{ kind: 'availability', values: ['live'] }],
eventFilters: [{ kind: 'surface', values: ['current'] }],
limit: 3,
cursor: 'next',
})
expect(normalizeEventRequest({ sessionId: SessionId('s'), query: 'needle' }, limits)).toEqual({
sessionId: SessionId('s'),
query: 'needle',
filters: [],
limit: 2,
})
expect(normalizeEventRequest({
sessionId: SessionId('s'),
query: 'needle',
filters: [{ kind: 'seq', from: 1 }],
cursor: 'next',
}, limits)).toEqual({
sessionId: SessionId('s'),
query: 'needle',
filters: [{ kind: 'seq', from: 1 }],
limit: 2,
cursor: 'next',
})
})
it('rejects non-text, blank, non-integer, non-positive, and oversized requests', () => {
expect(() => normalizeSessionRequest({ query: 1 as never }, limits))
.toThrow(expectCode('SESSION_QUERY_INVALID_QUERY'))
expect(() => normalizeSessionRequest({ query: ' \n ' }, limits))
.toThrow(expectCode('SESSION_QUERY_INVALID_QUERY'))
for (const limit of [1.5, 0, 4]) {
expect(() => normalizeEventRequest({ sessionId: SessionId('s'), query: 'x', limit }, limits))
.toThrow(expectCode('SESSION_QUERY_INVALID_LIMIT'))
}
})
})
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([{ kind: 'id', values: [SessionId('a'), SessionId('b')] }])).toEqual({
sql: 'session_id IN (?, ?)',
params: [SessionId('a'), SessionId('b')],
})
expect(buildSessionWhere([{ kind: 'cwd', values: [] }])).toEqual({ sql: '0', params: [] })
expect(buildSessionWhere([{ kind: 'cwd', values: [null] }])).toEqual({
sql: '(cwd IS NULL)',
params: [],
})
expect(buildSessionWhere([{ kind: 'cwd', values: ['/a'] }])).toEqual({
sql: '(cwd IN (?))',
params: ['/a'],
})
expect(buildSessionWhere([{ kind: 'parent', values: [SessionId('p'), null] }])).toEqual({
sql: '(parent_session IN (?) OR parent_session IS NULL)',
params: [SessionId('p')],
})
expect(buildSessionWhere([
{ kind: 'created-at', from: 1, to: 2 },
{ kind: 'availability', values: [] },
{ kind: 'availability', values: ['live', 'live'] },
{ kind: 'availability', values: ['live', 'persisted'] },
])).toEqual({
sql: 'CAST(created_at AS INTEGER) >= ? AND CAST(created_at AS INTEGER) <= ? AND 0 AND live = 1',
params: [1, 2],
})
expect(buildSessionWhere([{ kind: 'created-at' }])).toEqual({ sql: '', params: [] })
})
it('compiles every event clause and empty lists', () => {
expect(buildEventWhere([
{ kind: 'seq', from: 1 },
{ kind: 'time', to: 9 },
{ kind: 'type', values: ['user/message'] },
{ kind: 'surface', values: ['current', 'log-only'] },
])).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'],
})
expect(buildEventWhere([
{ kind: 'type', values: [] },
{ kind: 'surface', values: [] },
])).toEqual({ sql: '0 AND 0', params: [] })
})
})
describe('SQLite query identity and presentation', () => {
it('quotes all caller MATCH syntax as data', () => {
expect(quoteFtsData('say "needle" OR *')).toBe('"say ""needle"" OR *"')
})
it('canonicalizes request and filter ordering in both scopes', () => {
const sessionA: NormalizedSessionRequest = {
query: 'needle',
limit: 2,
sessionFilters: [
{ kind: 'cwd', values: ['/b', '/a'] },
{ kind: 'parent', values: [null, SessionId('p')] },
{ kind: 'id', values: [SessionId('same'), SessionId('same')] },
{ kind: 'created-at', from: 1 },
],
eventFilters: [{ kind: 'time', to: 9 }],
}
const sessionB: NormalizedSessionRequest = {
query: 'needle',
limit: 2,
sessionFilters: [
{ kind: 'created-at', from: 1 },
{ kind: 'id', values: [SessionId('same'), SessionId('same')] },
{ kind: 'parent', values: [SessionId('p'), null] },
{ kind: 'cwd', values: ['/a', '/b'] },
],
eventFilters: [{ kind: 'time', to: 9 }],
}
expect(requestFingerprint(sessionA)).toBe(requestFingerprint(sessionB))
const eventA: NormalizedEventRequest = {
sessionId: SessionId('s'),
query: 'needle',
limit: 2,
filters: [{ kind: 'seq' }, { kind: 'surface', values: ['shadowed', 'current'] }],
}
const eventB: NormalizedEventRequest = {
sessionId: SessionId('s'),
query: 'needle',
limit: 2,
filters: [{ kind: 'surface', values: ['current', 'shadowed'] }, { kind: 'seq' }],
}
expect(requestFingerprint(eventA)).toBe(requestFingerprint(eventB))
expect(requestFingerprint(eventA)).not.toBe(requestFingerprint({ ...eventB, sessionId: SessionId('other') }))
})
it('normalizes, bounds, and positions snippets by Unicode code point', () => {
expect(makeSnippet(' short\ntext ', 'absent', 20)).toBe('short text')
expect(makeSnippet('abcdef', 'f', 1)).toBe('…')
expect(makeSnippet('abcdefghij', 'absent', 5)).toBe('abcd…')
expect(makeSnippet('abcdefghij', 'c', 5)).toBe('…bcd…')
expect(makeSnippet('abcdef', 'f', 2)).toBe('a…')
expect(makeSnippet('abcdef', 'f', 5)).toBe('…cdef')
})
})