query.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477
  1. /** Request normalization, parameterized predicates, and result presentation. */
  2. import {
  3. SessionQueryError,
  4. materializeSessionEventResultFilters,
  5. materializeSessionResultFilters,
  6. } from '@deepseek-ai/dsh-session-query'
  7. import type {
  8. SessionAvailability,
  9. SessionEventMetadataFilter,
  10. SessionEventResultFilter,
  11. SessionEventSearchRequest,
  12. SessionResultFilter,
  13. SessionSearchCursor,
  14. SessionSearchRequest,
  15. } from '@deepseek-ai/dsh-session-query'
  16. /** Collision-free marker inserted before an FTS5 match by `highlight()`. */
  17. export const FTS_HIGHLIGHT_START = '\uFDD0'
  18. /** Collision-free marker inserted after an FTS5 match by `highlight()`. */
  19. export const FTS_HIGHLIGHT_END = '\uFDD1'
  20. /** Largest page size whose internal lookahead remains an exact SQLite integer binding. */
  21. export const SQLITE_MAX_PAGE_LIMIT = Number.MAX_SAFE_INTEGER - 1
  22. /** Portable host-parameter ceiling shared by predicate and statement builders. */
  23. export const SQLITE_PORTABLE_VARIABLE_LIMIT = 32_766
  24. /** Supported outer-predicate budget that keeps SQLite FTS5 MATCH usable. */
  25. export const SQLITE_FTS5_OUTER_PREDICATE_LIMIT = 14
  26. /**
  27. * Reject prospective SQLite binding growth beyond the portable ceiling.
  28. * @param count - binding count at the current construction boundary.
  29. */
  30. export function assertPortableBindingCount(count: number): void {
  31. if (count > SQLITE_PORTABLE_VARIABLE_LIMIT) {
  32. throw new SessionQueryError(
  33. `session-search request exceeds SQLite's portable ${SQLITE_PORTABLE_VARIABLE_LIMIT}-variable limit; reduce filter values`,
  34. 'SESSION_QUERY_INVALID_FILTER',
  35. )
  36. }
  37. }
  38. /**
  39. * Reject compiled outer predicates beyond the supported FTS5 planner budget.
  40. * @param count - predicate count including fixed statement predicates.
  41. */
  42. export function assertFts5OuterPredicateCount(count: number): void {
  43. if (count > SQLITE_FTS5_OUTER_PREDICATE_LIMIT) {
  44. throw new SessionQueryError(
  45. `session-search request exceeds the supported SQLite FTS5 outer-predicate budget of ${SQLITE_FTS5_OUTER_PREDICATE_LIMIT}; reduce filters`,
  46. 'SESSION_QUERY_INVALID_FILTER',
  47. )
  48. }
  49. }
  50. /** Limit defaults needed to normalize a search request. */
  51. export interface QueryLimits {
  52. /** Page size used when the request omits one. */
  53. defaultLimit: number
  54. /** Largest accepted page size. */
  55. maxLimit: number
  56. }
  57. /** Normalized cross-session request. */
  58. export interface NormalizedSessionRequest {
  59. query: string
  60. sessionFilters: readonly SessionResultFilter[]
  61. eventFilters: readonly SessionEventMetadataFilter[]
  62. limit: number
  63. cursor?: SessionSearchCursor
  64. }
  65. /** Normalized within-session request. */
  66. export interface NormalizedEventRequest {
  67. sessionId: SessionEventSearchRequest['sessionId']
  68. query: string
  69. filters: readonly SessionEventMetadataFilter[]
  70. limit: number
  71. cursor?: SessionSearchCursor
  72. }
  73. /** Parameterized SQL predicate fragment. */
  74. export interface SqlWhere {
  75. /** SQL without the leading `WHERE`. */
  76. sql: string
  77. /** Bindings in placeholder order. */
  78. params: Array<string | number>
  79. /** Number of compiled predicates in `sql`. */
  80. predicateCount: number
  81. }
  82. /**
  83. * Validate and canonicalize a cross-session request.
  84. * @param request - caller-provided query, filters, limit, and cursor.
  85. * @param limits - configured default and maximum page sizes.
  86. * @returns normalized request with explicit arrays and limit.
  87. */
  88. export function normalizeSessionRequest(
  89. request: SessionSearchRequest,
  90. limits: QueryLimits,
  91. ): NormalizedSessionRequest {
  92. const sessionFilters = materializeSessionResultFilters(request.sessionFilters ?? [])
  93. const eventFilters = materializeMetadataFilters(request.eventFilters ?? [])
  94. const cursor = materializeCursor(request.cursor)
  95. return {
  96. query: normalizeQuery(request.query),
  97. sessionFilters,
  98. eventFilters,
  99. limit: normalizeLimit(request.limit, limits),
  100. ...cursor === undefined ? {} : { cursor },
  101. }
  102. }
  103. /**
  104. * Validate and canonicalize a within-session request.
  105. * @param request - caller-provided target, query, filters, limit, and cursor.
  106. * @param limits - configured default and maximum page sizes.
  107. * @returns normalized request with an explicit filter array and limit.
  108. */
  109. export function normalizeEventRequest(
  110. request: SessionEventSearchRequest,
  111. limits: QueryLimits,
  112. ): NormalizedEventRequest {
  113. if (typeof request.sessionId !== 'string') {
  114. throw new SessionQueryError('session-search session id must be text', 'SESSION_QUERY_INVALID_FILTER')
  115. }
  116. const filters = materializeMetadataFilters(request.filters ?? [])
  117. const cursor = materializeCursor(request.cursor)
  118. return {
  119. sessionId: request.sessionId,
  120. query: normalizeQuery(request.query),
  121. filters,
  122. limit: normalizeLimit(request.limit, limits),
  123. ...cursor === undefined ? {} : { cursor },
  124. }
  125. }
  126. /**
  127. * Compile logical-session predicates against selected-document columns.
  128. * @param filters - validated ANDed logical-session clauses.
  129. * @returns parameterized SQL fragment and ordered bindings.
  130. */
  131. export function buildSessionWhere(filters: readonly SessionResultFilter[]): SqlWhere {
  132. const clauses: string[] = []
  133. const params: Array<string | number> = []
  134. for (const filter of filters) {
  135. switch (filter.kind) {
  136. case 'id':
  137. addList(clauses, params, 'session_id', filter.values)
  138. break
  139. case 'cwd':
  140. addNullableList(clauses, params, 'cwd', filter.values)
  141. break
  142. case 'created-at':
  143. addRange(clauses, params, 'created_at', filter)
  144. break
  145. case 'parent':
  146. addNullableList(clauses, params, 'parent_session', filter.values)
  147. break
  148. case 'availability': {
  149. const availability = [...new Set(filter.values)]
  150. if (availability.length === 0) clauses.push('0')
  151. else if (availability.length === 1) {
  152. const value = availability[0] as SessionAvailability
  153. switch (value) {
  154. case 'live':
  155. clauses.push('live = 1')
  156. break
  157. case 'persisted':
  158. clauses.push('persisted = 1')
  159. break
  160. default:
  161. unknownAvailability(value)
  162. }
  163. }
  164. break
  165. }
  166. default:
  167. unknownFilter(filter)
  168. }
  169. }
  170. assertFts5OuterPredicateCount(clauses.length)
  171. return { sql: clauses.join(' AND '), params, predicateCount: clauses.length }
  172. }
  173. /**
  174. * Compile event metadata predicates against selected-document columns.
  175. * @param filters - validated ANDed event metadata clauses.
  176. * @returns parameterized SQL fragment and ordered bindings.
  177. */
  178. export function buildEventWhere(filters: readonly SessionEventMetadataFilter[]): SqlWhere {
  179. const clauses: string[] = []
  180. const params: Array<string | number> = []
  181. for (const filter of filters) {
  182. switch (filter.kind) {
  183. case 'seq':
  184. addRange(clauses, params, 'seq', filter)
  185. break
  186. case 'time':
  187. addRange(clauses, params, 'time', filter)
  188. break
  189. case 'type':
  190. addList(clauses, params, 'type', filter.values)
  191. break
  192. case 'surface':
  193. addList(clauses, params, 'surface', filter.values)
  194. break
  195. default:
  196. unknownFilter(filter)
  197. }
  198. }
  199. assertFts5OuterPredicateCount(clauses.length)
  200. return { sql: clauses.join(' AND '), params, predicateCount: clauses.length }
  201. }
  202. /**
  203. * Quote caller text as one FTS5 phrase so query syntax remains inert data.
  204. * @param query - normalized caller query.
  205. * @returns FTS5 expression containing one escaped literal phrase.
  206. */
  207. export function quoteFtsData(query: string): string {
  208. return `"${query.replaceAll('"', '""')}"`
  209. }
  210. /**
  211. * Remove reserved marker collisions before text enters FTS5 or MATCH.
  212. * @param text - extracted document text or normalized caller query.
  213. * @returns text with reserved noncharacters mapped to replacement characters.
  214. */
  215. export function sanitizeFtsText(text: string): string {
  216. return text
  217. .replaceAll('\0', '\uFFFD')
  218. .replaceAll(FTS_HIGHLIGHT_START, '\uFFFD')
  219. .replaceAll(FTS_HIGHLIGHT_END, '\uFFFD')
  220. }
  221. /**
  222. * Build the stable normalized request identity stored in opaque cursors.
  223. * @param request - normalized request whose filter ordering is canonicalized.
  224. * @returns deterministic JSON identity for cursor binding.
  225. */
  226. export function requestFingerprint(request: NormalizedSessionRequest | NormalizedEventRequest): string {
  227. if ('sessionId' in request) {
  228. return JSON.stringify({
  229. scope: 'events',
  230. sessionId: request.sessionId,
  231. query: request.query,
  232. filters: canonicalFilters(request.filters),
  233. limit: request.limit,
  234. })
  235. }
  236. return JSON.stringify({
  237. scope: 'sessions',
  238. query: request.query,
  239. sessionFilters: canonicalFilters(request.sessionFilters),
  240. eventFilters: canonicalFilters(request.eventFilters),
  241. limit: request.limit,
  242. })
  243. }
  244. /**
  245. * Build a whitespace-normalized excerpt no longer than `maxChars`.
  246. * @param markedText - complete document with FTS5 `highlight()` markers.
  247. * @param maxChars - maximum result length in Unicode code points.
  248. * @returns bounded plain-text snippet.
  249. */
  250. export function makeSnippet(markedText: string, maxChars: number): string {
  251. const { text: clean, matchStart } = normalizeMarkedText(markedText)
  252. const characters = Array.from(clean)
  253. if (characters.length <= maxChars) return clean
  254. if (maxChars === 1) return '…'
  255. const matchedIndex = Math.min(matchStart, characters.length - 1)
  256. let start = Math.max(0, matchedIndex - Math.floor(maxChars / 3))
  257. const prefix = start > 0 ? '…' : ''
  258. let suffix = '…'
  259. let contentLength = maxChars - prefix.length - suffix.length
  260. if (contentLength < 1) {
  261. start = matchedIndex
  262. suffix = ''
  263. contentLength = maxChars - prefix.length - suffix.length
  264. } else if (matchedIndex >= start + contentLength) {
  265. start = matchedIndex - contentLength + 1
  266. }
  267. let end = Math.min(characters.length, start + contentLength)
  268. if (end === characters.length) {
  269. suffix = ''
  270. contentLength = maxChars - prefix.length
  271. start = Math.max(0, end - contentLength)
  272. }
  273. end = Math.min(characters.length, start + contentLength)
  274. return `${prefix}${characters.slice(start, end).join('')}${suffix}`
  275. }
  276. function normalizeMarkedText(markedText: string): { text: string; matchStart: number } {
  277. const characters: string[] = []
  278. let matchStart: number | undefined
  279. for (const character of markedText) {
  280. if (character === FTS_HIGHLIGHT_START) {
  281. matchStart ??= characters.length
  282. continue
  283. }
  284. if (character === FTS_HIGHLIGHT_END) continue
  285. if (/\s/u.test(character)) {
  286. if (characters.length > 0 && characters.at(-1) !== ' ') characters.push(' ')
  287. } else {
  288. characters.push(character)
  289. }
  290. }
  291. if (characters.at(-1) === ' ') characters.pop()
  292. return {
  293. text: characters.join(''),
  294. matchStart: matchStart ?? 0,
  295. }
  296. }
  297. function normalizeQuery(value: string): string {
  298. if (typeof value !== 'string') {
  299. throw new SessionQueryError('session-search query must be text', 'SESSION_QUERY_INVALID_QUERY')
  300. }
  301. const query = value.trim().replace(/\s+/gu, ' ')
  302. if (query.length === 0) {
  303. throw new SessionQueryError(
  304. 'session-search query must contain non-whitespace text',
  305. 'SESSION_QUERY_INVALID_QUERY',
  306. )
  307. }
  308. if (query.includes('\0')) {
  309. throw new SessionQueryError(
  310. 'session-search query must not contain NUL',
  311. 'SESSION_QUERY_INVALID_QUERY',
  312. )
  313. }
  314. return sanitizeFtsText(query)
  315. }
  316. function materializeCursor(cursor: SessionSearchCursor | undefined): SessionSearchCursor | undefined {
  317. if (cursor === undefined) return undefined
  318. if (typeof cursor !== 'string') {
  319. throw new SessionQueryError('session-search cursor must be text', 'SESSION_QUERY_INVALID_CURSOR')
  320. }
  321. return cursor
  322. }
  323. function materializeMetadataFilters(
  324. filters: readonly SessionEventMetadataFilter[],
  325. ): SessionEventMetadataFilter[] {
  326. const candidates: readonly SessionEventResultFilter[] = filters
  327. for (const filter of candidates) {
  328. switch (filter.kind) {
  329. case 'seq':
  330. case 'time':
  331. case 'type':
  332. case 'surface':
  333. break
  334. case 'text':
  335. throw new SessionQueryError(
  336. 'session-search metadata filters do not accept text clauses',
  337. 'SESSION_QUERY_INVALID_FILTER',
  338. )
  339. default:
  340. unknownFilter(filter)
  341. }
  342. }
  343. return materializeSessionEventResultFilters(filters) as SessionEventMetadataFilter[]
  344. }
  345. function normalizeLimit(value: number | undefined, limits: QueryLimits): number {
  346. const limit = value ?? limits.defaultLimit
  347. const maxLimit = Math.min(limits.maxLimit, SQLITE_MAX_PAGE_LIMIT)
  348. if (
  349. !Number.isSafeInteger(limit)
  350. || limit < 1
  351. || limit > maxLimit
  352. ) {
  353. throw new SessionQueryError(
  354. `session-search limit must be an integer between 1 and ${maxLimit}`,
  355. 'SESSION_QUERY_INVALID_LIMIT',
  356. )
  357. }
  358. return limit
  359. }
  360. function addList(
  361. clauses: string[],
  362. params: Array<string | number>,
  363. column: string,
  364. values: readonly (string | number)[],
  365. ): void {
  366. if (values.length === 0) {
  367. clauses.push('0')
  368. return
  369. }
  370. clauses.push(`${column} IN (${appendListBindings(params, values)})`)
  371. }
  372. function addNullableList(
  373. clauses: string[],
  374. params: Array<string | number>,
  375. column: string,
  376. values: readonly (string | null)[],
  377. ): void {
  378. if (values.length === 0) {
  379. clauses.push('0')
  380. return
  381. }
  382. const concrete = values.filter((value): value is string => value !== null)
  383. const parts: string[] = []
  384. if (concrete.length > 0) {
  385. parts.push(`${column} IN (${appendListBindings(params, concrete)})`)
  386. }
  387. if (values.includes(null)) parts.push(`${column} IS NULL`)
  388. clauses.push(`(${parts.join(' OR ')})`)
  389. }
  390. function addRange(
  391. clauses: string[],
  392. params: Array<string | number>,
  393. column: string,
  394. range: { from?: number; to?: number },
  395. ): void {
  396. if (range.from !== undefined) {
  397. assertPortableBindingCount(params.length + 1)
  398. clauses.push(`CAST(${column} AS INTEGER) >= ?`)
  399. params.push(range.from)
  400. }
  401. if (range.to !== undefined) {
  402. assertPortableBindingCount(params.length + 1)
  403. clauses.push(`CAST(${column} AS INTEGER) <= ?`)
  404. params.push(range.to)
  405. }
  406. }
  407. function appendListBindings(
  408. params: Array<string | number>,
  409. values: readonly (string | number)[],
  410. ): string {
  411. assertPortableBindingCount(params.length + values.length)
  412. for (const value of values) params.push(value)
  413. return values.map(() => '?').join(', ')
  414. }
  415. function canonicalFilters(filters: readonly (SessionResultFilter | SessionEventMetadataFilter)[]): unknown[] {
  416. return filters.map((filter) => {
  417. if ('values' in filter) {
  418. return { ...filter, values: [...filter.values].sort(compareNullable) }
  419. }
  420. return {
  421. kind: filter.kind,
  422. from: filter.from ?? null,
  423. to: filter.to ?? null,
  424. }
  425. }).sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b)))
  426. }
  427. function compareNullable(a: string | null, b: string | null): number {
  428. if (a === b) return 0
  429. if (a === null) return -1
  430. if (b === null) return 1
  431. return a.localeCompare(b)
  432. }
  433. function unknownAvailability(value: never): never {
  434. throw new SessionQueryError(
  435. `session availability filter contains unknown value "${String(value)}"`,
  436. 'SESSION_QUERY_INVALID_FILTER',
  437. )
  438. }
  439. function unknownFilter(filter: never): never {
  440. const kind = (filter as { kind?: unknown }).kind
  441. throw new SessionQueryError(
  442. `session filter contains unknown kind ${typeof kind === 'string' ? `"${kind}"` : '(missing)'}`,
  443. 'SESSION_QUERY_INVALID_FILTER',
  444. )
  445. }