/** Request normalization, parameterized predicates, and result presentation. */ import { SessionQueryError, materializeSessionEventResultFilters, materializeSessionResultFilters, } from '@deepseek-ai/dsh-session-query' import type { SessionAvailability, SessionEventMetadataFilter, SessionEventResultFilter, SessionEventSearchRequest, SessionResultFilter, SessionSearchCursor, SessionSearchRequest, } from '@deepseek-ai/dsh-session-query' /** Collision-free marker inserted before an FTS5 match by `highlight()`. */ export const FTS_HIGHLIGHT_START = '\uFDD0' /** Collision-free marker inserted after an FTS5 match by `highlight()`. */ 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 /** Supported outer-predicate budget that keeps SQLite FTS5 MATCH usable. */ export const SQLITE_FTS5_OUTER_PREDICATE_LIMIT = 14 /** * 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', ) } } /** * Reject compiled outer predicates beyond the supported FTS5 planner budget. * @param count - predicate count including fixed statement predicates. */ export function assertFts5OuterPredicateCount(count: number): void { if (count > SQLITE_FTS5_OUTER_PREDICATE_LIMIT) { throw new SessionQueryError( `session-search request exceeds the supported SQLite FTS5 outer-predicate budget of ${SQLITE_FTS5_OUTER_PREDICATE_LIMIT}; reduce filters`, 'SESSION_QUERY_INVALID_FILTER', ) } } /** 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?: SessionSearchCursor } /** Normalized within-session request. */ export interface NormalizedEventRequest { sessionId: SessionEventSearchRequest['sessionId'] query: string filters: readonly SessionEventMetadataFilter[] limit: number cursor?: SessionSearchCursor } /** Parameterized SQL predicate fragment. */ export interface SqlWhere { /** SQL without the leading `WHERE`. */ sql: string /** Bindings in placeholder order. */ params: Array /** Number of compiled predicates in `sql`. */ predicateCount: 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 = materializeSessionResultFilters(request.sessionFilters ?? []) const eventFilters = materializeMetadataFilters(request.eventFilters ?? []) const cursor = materializeCursor(request.cursor) return { query: normalizeQuery(request.query), sessionFilters, eventFilters, limit: normalizeLimit(request.limit, limits), ...cursor === undefined ? {} : { 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 { if (typeof request.sessionId !== 'string') { throw new SessionQueryError('session-search session id must be text', 'SESSION_QUERY_INVALID_FILTER') } const filters = materializeMetadataFilters(request.filters ?? []) const cursor = materializeCursor(request.cursor) return { sessionId: request.sessionId, query: normalizeQuery(request.query), filters, limit: normalizeLimit(request.limit, limits), ...cursor === undefined ? {} : { 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 = [] 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) { const value = availability[0] as SessionAvailability switch (value) { case 'live': clauses.push('live = 1') break case 'persisted': clauses.push('persisted = 1') break default: unknownAvailability(value) } } break } default: unknownFilter(filter) } } assertFts5OuterPredicateCount(clauses.length) return { sql: clauses.join(' AND '), params, predicateCount: clauses.length } } /** * 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 = [] 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 default: unknownFilter(filter) } } assertFts5OuterPredicateCount(clauses.length) return { sql: clauses.join(' AND '), params, predicateCount: clauses.length } } /** * 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('"', '""')}"` } /** * Remove reserved marker collisions before text enters FTS5 or MATCH. * @param text - extracted document text or normalized caller query. * @returns text with reserved noncharacters mapped to replacement characters. */ export function sanitizeFtsText(text: string): string { return text .replaceAll('\0', '\uFFFD') .replaceAll(FTS_HIGHLIGHT_START, '\uFFFD') .replaceAll(FTS_HIGHLIGHT_END, '\uFFFD') } /** * 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 markedText - complete document with FTS5 `highlight()` markers. * @param maxChars - maximum result length in Unicode code points. * @returns bounded plain-text snippet. */ export function makeSnippet(markedText: string, maxChars: number): string { const { text: clean, matchStart } = normalizeMarkedText(markedText) const characters = Array.from(clean) if (characters.length <= maxChars) return clean if (maxChars === 1) return '…' const matchedIndex = Math.min(matchStart, characters.length - 1) let start = Math.max(0, matchedIndex - Math.floor(maxChars / 3)) const prefix = start > 0 ? '…' : '' let suffix = '…' let contentLength = maxChars - prefix.length - suffix.length if (contentLength < 1) { start = matchedIndex suffix = '' contentLength = maxChars - prefix.length - suffix.length } else if (matchedIndex >= start + contentLength) { start = matchedIndex - contentLength + 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 normalizeMarkedText(markedText: string): { text: string; matchStart: number } { const characters: string[] = [] let matchStart: number | undefined for (const character of markedText) { if (character === FTS_HIGHLIGHT_START) { matchStart ??= characters.length continue } if (character === FTS_HIGHLIGHT_END) continue if (/\s/u.test(character)) { if (characters.length > 0 && characters.at(-1) !== ' ') characters.push(' ') } else { characters.push(character) } } if (characters.at(-1) === ' ') characters.pop() return { text: characters.join(''), matchStart: matchStart ?? 0, } } 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', ) } if (query.includes('\0')) { throw new SessionQueryError( 'session-search query must not contain NUL', 'SESSION_QUERY_INVALID_QUERY', ) } return sanitizeFtsText(query) } function materializeCursor(cursor: SessionSearchCursor | undefined): SessionSearchCursor | undefined { if (cursor === undefined) return undefined if (typeof cursor !== 'string') { throw new SessionQueryError('session-search cursor must be text', 'SESSION_QUERY_INVALID_CURSOR') } return cursor } function materializeMetadataFilters( filters: readonly SessionEventMetadataFilter[], ): SessionEventMetadataFilter[] { const candidates: readonly SessionEventResultFilter[] = filters for (const filter of candidates) { switch (filter.kind) { case 'seq': case 'time': case 'type': case 'surface': break case 'text': throw new SessionQueryError( 'session-search metadata filters do not accept text clauses', 'SESSION_QUERY_INVALID_FILTER', ) default: unknownFilter(filter) } } return materializeSessionEventResultFilters(filters) as SessionEventMetadataFilter[] } function normalizeLimit(value: number | undefined, limits: QueryLimits): number { const limit = value ?? limits.defaultLimit const maxLimit = Math.min(limits.maxLimit, SQLITE_MAX_PAGE_LIMIT) if ( !Number.isSafeInteger(limit) || limit < 1 || limit > maxLimit ) { throw new SessionQueryError( `session-search limit must be an integer between 1 and ${maxLimit}`, 'SESSION_QUERY_INVALID_LIMIT', ) } return limit } function addList( clauses: string[], params: Array, column: string, values: readonly (string | number)[], ): void { if (values.length === 0) { clauses.push('0') return } clauses.push(`${column} IN (${appendListBindings(params, values)})`) } function addNullableList( clauses: string[], params: Array, 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 (${appendListBindings(params, concrete)})`) } if (values.includes(null)) parts.push(`${column} IS NULL`) clauses.push(`(${parts.join(' OR ')})`) } function addRange( clauses: string[], params: Array, column: string, 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) { 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) } function unknownAvailability(value: never): never { throw new SessionQueryError( `session availability filter contains unknown value "${String(value)}"`, 'SESSION_QUERY_INVALID_FILTER', ) } function unknownFilter(filter: never): never { const kind = (filter as { kind?: unknown }).kind throw new SessionQueryError( `session filter contains unknown kind ${typeof kind === 'string' ? `"${kind}"` : '(missing)'}`, 'SESSION_QUERY_INVALID_FILTER', ) }