| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477 |
- /** 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<string | number>
- /** 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<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) {
- 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<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
- 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<string | number>,
- 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<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 (${appendListBindings(params, 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) {
- 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) {
- 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',
- )
- }
|