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
@@ -0,0 +1,312 @@
/** Request normalization, parameterized predicates, and result presentation. */
import {
SessionQueryError,
filterSessionEventDocuments,
filterSessionResults,
} from '@deepseek-ai/dsh-session-query'
import type {
SessionEventMetadataFilter,
SessionEventSearchRequest,
SessionResultFilter,
SessionSearchRequest,
} from '@deepseek-ai/dsh-session-query'
/** Limit defaults needed to normalize a search request. */
export interface QueryLimits {
/** Page size used when the request omits one. */
defaultLimit: number
/** Largest accepted page size. */
maxLimit: number
}
/** Normalized cross-session request. */
export interface NormalizedSessionRequest {
query: string
sessionFilters: readonly SessionResultFilter[]
eventFilters: readonly SessionEventMetadataFilter[]
limit: number
cursor?: string
}
/** Normalized within-session request. */
export interface NormalizedEventRequest {
sessionId: SessionEventSearchRequest['sessionId']
query: string
filters: readonly SessionEventMetadataFilter[]
limit: number
cursor?: string
}
/** Parameterized SQL predicate fragment. */
export interface SqlWhere {
/** SQL without the leading `WHERE`. */
sql: string
/** Bindings in placeholder order. */
params: Array<string | number>
}
/**
* Validate and canonicalize a cross-session request.
* @param request - caller-provided query, filters, limit, and cursor.
* @param limits - configured default and maximum page sizes.
* @returns normalized request with explicit arrays and limit.
*/
export function normalizeSessionRequest(
request: SessionSearchRequest,
limits: QueryLimits,
): NormalizedSessionRequest {
const sessionFilters = request.sessionFilters ?? []
const eventFilters = request.eventFilters ?? []
filterSessionResults([], sessionFilters)
filterSessionEventDocuments([], eventFilters)
return {
query: normalizeQuery(request.query),
sessionFilters,
eventFilters,
limit: normalizeLimit(request.limit, limits),
...request.cursor === undefined ? {} : { cursor: request.cursor },
}
}
/**
* Validate and canonicalize a within-session request.
* @param request - caller-provided target, query, filters, limit, and cursor.
* @param limits - configured default and maximum page sizes.
* @returns normalized request with an explicit filter array and limit.
*/
export function normalizeEventRequest(
request: SessionEventSearchRequest,
limits: QueryLimits,
): NormalizedEventRequest {
const filters = request.filters ?? []
filterSessionEventDocuments([], filters)
return {
sessionId: request.sessionId,
query: normalizeQuery(request.query),
filters,
limit: normalizeLimit(request.limit, limits),
...request.cursor === undefined ? {} : { cursor: request.cursor },
}
}
/**
* Compile logical-session predicates against selected-document columns.
* @param filters - validated ANDed logical-session clauses.
* @returns parameterized SQL fragment and ordered bindings.
*/
export function buildSessionWhere(filters: readonly SessionResultFilter[]): SqlWhere {
const clauses: string[] = []
const params: Array<string | number> = []
for (const filter of filters) {
switch (filter.kind) {
case 'id':
addList(clauses, params, 'session_id', filter.values)
break
case 'cwd':
addNullableList(clauses, params, 'cwd', filter.values)
break
case 'created-at':
addRange(clauses, params, 'created_at', filter)
break
case 'parent':
addNullableList(clauses, params, 'parent_session', filter.values)
break
case 'availability': {
const availability = [...new Set(filter.values)]
if (availability.length === 0) clauses.push('0')
else if (availability.length === 1) clauses.push(`${availability[0]} = 1`)
break
}
}
}
return { sql: clauses.join(' AND '), params }
}
/**
* Compile event metadata predicates against selected-document columns.
* @param filters - validated ANDed event metadata clauses.
* @returns parameterized SQL fragment and ordered bindings.
*/
export function buildEventWhere(filters: readonly SessionEventMetadataFilter[]): SqlWhere {
const clauses: string[] = []
const params: Array<string | number> = []
for (const filter of filters) {
switch (filter.kind) {
case 'seq':
addRange(clauses, params, 'seq', filter)
break
case 'time':
addRange(clauses, params, 'time', filter)
break
case 'type':
addList(clauses, params, 'type', filter.values)
break
case 'surface':
addList(clauses, params, 'surface', filter.values)
break
}
}
return { sql: clauses.join(' AND '), params }
}
/**
* Quote caller text as one FTS5 phrase so query syntax remains inert data.
* @param query - normalized caller query.
* @returns FTS5 expression containing one escaped literal phrase.
*/
export function quoteFtsData(query: string): string {
return `"${query.replaceAll('"', '""')}"`
}
/**
* Build the stable normalized request identity stored in opaque cursors.
* @param request - normalized request whose filter ordering is canonicalized.
* @returns deterministic JSON identity for cursor binding.
*/
export function requestFingerprint(request: NormalizedSessionRequest | NormalizedEventRequest): string {
if ('sessionId' in request) {
return JSON.stringify({
scope: 'events',
sessionId: request.sessionId,
query: request.query,
filters: canonicalFilters(request.filters),
limit: request.limit,
})
}
return JSON.stringify({
scope: 'sessions',
query: request.query,
sessionFilters: canonicalFilters(request.sessionFilters),
eventFilters: canonicalFilters(request.eventFilters),
limit: request.limit,
})
}
/**
* Build a whitespace-normalized excerpt no longer than `maxChars`.
* @param text - complete extracted semantic document.
* @param query - normalized literal query used to position the excerpt.
* @param maxChars - maximum result length in Unicode code points.
* @returns bounded plain-text snippet.
*/
export function makeSnippet(text: string, query: string, maxChars: number): string {
const clean = text.replace(/\s+/gu, ' ').trim()
const characters = Array.from(clean)
if (characters.length <= maxChars) return clean
if (maxChars === 1) return '…'
const foundUnits = clean.toLowerCase().indexOf(query.toLowerCase())
const found = foundUnits < 0 ? -1 : Array.from(clean.slice(0, foundUnits)).length
let start = found < 0 ? 0 : Math.max(0, found - Math.floor(maxChars / 3))
let prefix = start > 0 ? '…' : ''
let suffix = '…'
let contentLength = maxChars - prefix.length - suffix.length
if (contentLength < 1) {
start = 0
prefix = ''
contentLength = maxChars - 1
}
let end = Math.min(characters.length, start + contentLength)
if (end === characters.length) {
suffix = ''
contentLength = maxChars - prefix.length
start = Math.max(0, end - contentLength)
}
end = Math.min(characters.length, start + contentLength)
return `${prefix}${characters.slice(start, end).join('')}${suffix}`
}
function normalizeQuery(value: string): string {
if (typeof value !== 'string') {
throw new SessionQueryError('session-search query must be text', 'SESSION_QUERY_INVALID_QUERY')
}
const query = value.trim().replace(/\s+/gu, ' ')
if (query.length === 0) {
throw new SessionQueryError(
'session-search query must contain non-whitespace text',
'SESSION_QUERY_INVALID_QUERY',
)
}
return query
}
function normalizeLimit(value: number | undefined, limits: QueryLimits): number {
const limit = value ?? limits.defaultLimit
if (!Number.isInteger(limit) || limit < 1 || limit > limits.maxLimit) {
throw new SessionQueryError(
`session-search limit must be an integer between 1 and ${limits.maxLimit}`,
'SESSION_QUERY_INVALID_LIMIT',
)
}
return limit
}
function addList(
clauses: string[],
params: Array<string | number>,
column: string,
values: readonly (string | number)[],
): void {
if (values.length === 0) {
clauses.push('0')
return
}
clauses.push(`${column} IN (${values.map(() => '?').join(', ')})`)
params.push(...values)
}
function addNullableList(
clauses: string[],
params: Array<string | number>,
column: string,
values: readonly (string | null)[],
): void {
if (values.length === 0) {
clauses.push('0')
return
}
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)
}
if (values.includes(null)) parts.push(`${column} IS NULL`)
clauses.push(`(${parts.join(' OR ')})`)
}
function addRange(
clauses: string[],
params: Array<string | number>,
column: string,
range: { from?: number; to?: number },
): void {
if (range.from !== undefined) {
clauses.push(`CAST(${column} AS INTEGER) >= ?`)
params.push(range.from)
}
if (range.to !== undefined) {
clauses.push(`CAST(${column} AS INTEGER) <= ?`)
params.push(range.to)
}
}
function canonicalFilters(filters: readonly (SessionResultFilter | SessionEventMetadataFilter)[]): unknown[] {
return filters.map((filter) => {
if ('values' in filter) {
return { ...filter, values: [...filter.values].sort(compareNullable) }
}
return {
kind: filter.kind,
from: filter.from ?? null,
to: filter.to ?? null,
}
}).sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b)))
}
function compareNullable(a: string | null, b: string | null): number {
if (a === b) return 0
if (a === null) return -1
if (b === null) return 1
return a.localeCompare(b)
}