query.spec.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262
  1. import { describe, expect, it } from 'vitest'
  2. import { SessionId } from '@deepseek-ai/dsh-session'
  3. import { SessionSearchCursor, type SessionQueryErrorCode } from '@deepseek-ai/dsh-session-query'
  4. import {
  5. buildEventWhere,
  6. buildSessionWhere,
  7. FTS_HIGHLIGHT_END,
  8. FTS_HIGHLIGHT_START,
  9. makeSnippet,
  10. normalizeEventRequest,
  11. normalizeSessionRequest,
  12. quoteFtsData,
  13. requestFingerprint,
  14. SQLITE_FTS5_OUTER_PREDICATE_LIMIT,
  15. SQLITE_MAX_PAGE_LIMIT,
  16. type NormalizedEventRequest,
  17. type NormalizedSessionRequest,
  18. } from '../src/query.ts'
  19. const limits = { defaultLimit: 2, maxLimit: 3 }
  20. function expectCode(code: SessionQueryErrorCode): Error {
  21. return expect.objectContaining({ code }) as Error
  22. }
  23. describe('SQLite search request normalization', () => {
  24. it('normalizes both scopes, defaults arrays and limits, and preserves cursors', () => {
  25. expect(normalizeSessionRequest({ query: ' alpha\n beta ' }, limits)).toEqual({
  26. query: 'alpha beta',
  27. sessionFilters: [],
  28. eventFilters: [],
  29. limit: 2,
  30. })
  31. expect(normalizeSessionRequest({
  32. query: 'needle',
  33. sessionFilters: [{ kind: 'availability', values: ['live'] }],
  34. eventFilters: [{ kind: 'surface', values: ['current'] }],
  35. limit: 3,
  36. cursor: SessionSearchCursor('next'),
  37. }, limits)).toEqual({
  38. query: 'needle',
  39. sessionFilters: [{ kind: 'availability', values: ['live'] }],
  40. eventFilters: [{ kind: 'surface', values: ['current'] }],
  41. limit: 3,
  42. cursor: SessionSearchCursor('next'),
  43. })
  44. expect(normalizeEventRequest({ sessionId: SessionId('s'), query: 'needle' }, limits)).toEqual({
  45. sessionId: SessionId('s'),
  46. query: 'needle',
  47. filters: [],
  48. limit: 2,
  49. })
  50. expect(normalizeEventRequest({
  51. sessionId: SessionId('s'),
  52. query: 'needle',
  53. filters: [{ kind: 'seq', from: 1 }],
  54. cursor: SessionSearchCursor('next'),
  55. }, limits)).toEqual({
  56. sessionId: SessionId('s'),
  57. query: 'needle',
  58. filters: [{ kind: 'seq', from: 1 }],
  59. limit: 2,
  60. cursor: SessionSearchCursor('next'),
  61. })
  62. })
  63. it('rejects non-text, blank, non-integer, non-positive, and oversized requests', () => {
  64. expect(() => normalizeSessionRequest({ query: 1 as never }, limits))
  65. .toThrow(expectCode('SESSION_QUERY_INVALID_QUERY'))
  66. expect(() => normalizeSessionRequest({ query: ' \n ' }, limits))
  67. .toThrow(expectCode('SESSION_QUERY_INVALID_QUERY'))
  68. expect(() => normalizeSessionRequest({ query: 'bad\0query' }, limits))
  69. .toThrow(expectCode('SESSION_QUERY_INVALID_QUERY'))
  70. expect(() => normalizeEventRequest({ sessionId: 1 as never, query: 'x' }, limits))
  71. .toThrow(expectCode('SESSION_QUERY_INVALID_FILTER'))
  72. expect(() => normalizeEventRequest({
  73. sessionId: SessionId('s'),
  74. query: 'x',
  75. cursor: 1 as never,
  76. }, limits)).toThrow(expectCode('SESSION_QUERY_INVALID_CURSOR'))
  77. expect(() => normalizeSessionRequest({
  78. query: 'x',
  79. eventFilters: [{ kind: 'text', text: 'x' } as never],
  80. }, limits)).toThrow(expectCode('SESSION_QUERY_INVALID_FILTER'))
  81. expect(() => normalizeSessionRequest({
  82. query: 'x',
  83. eventFilters: [{} as never],
  84. }, limits)).toThrow(expectCode('SESSION_QUERY_INVALID_FILTER'))
  85. for (const limit of [1.5, 0, 4]) {
  86. expect(() => normalizeEventRequest({ sessionId: SessionId('s'), query: 'x', limit }, limits))
  87. .toThrow(expectCode('SESSION_QUERY_INVALID_LIMIT'))
  88. }
  89. expect(() => normalizeEventRequest({
  90. sessionId: SessionId('s'),
  91. query: 'x',
  92. limit: SQLITE_MAX_PAGE_LIMIT + 1,
  93. }, { defaultLimit: 1, maxLimit: SQLITE_MAX_PAGE_LIMIT + 1 }))
  94. .toThrow(expectCode('SESSION_QUERY_INVALID_LIMIT'))
  95. })
  96. it('materializes owned filter values during normalization', () => {
  97. const values = ['live'] as Array<'live' | 'persisted'>
  98. const filter = { kind: 'availability' as const, values }
  99. const request = { query: 'needle', sessionFilters: [filter] }
  100. const normalized = normalizeSessionRequest(request, limits)
  101. values[0] = 'persisted'
  102. request.sessionFilters.push({ kind: 'availability', values: ['persisted'] })
  103. expect(normalized.sessionFilters).toEqual([{ kind: 'availability', values: ['live'] }])
  104. })
  105. })
  106. describe('SQLite search predicate compilation', () => {
  107. it('compiles all logical-session clauses including empty and nullable values', () => {
  108. expect(buildSessionWhere([])).toEqual({ sql: '', params: [], predicateCount: 0 })
  109. expect(buildSessionWhere([{ kind: 'id', values: [] }])).toEqual({
  110. sql: '0',
  111. params: [],
  112. predicateCount: 1,
  113. })
  114. expect(buildSessionWhere([{ kind: 'id', values: [SessionId('a'), SessionId('b')] }])).toEqual({
  115. sql: 'session_id IN (?, ?)',
  116. params: [SessionId('a'), SessionId('b')],
  117. predicateCount: 1,
  118. })
  119. expect(buildSessionWhere([{ kind: 'cwd', values: [] }])).toEqual({
  120. sql: '0',
  121. params: [],
  122. predicateCount: 1,
  123. })
  124. expect(buildSessionWhere([{ kind: 'cwd', values: [null] }])).toEqual({
  125. sql: '(cwd IS NULL)',
  126. params: [],
  127. predicateCount: 1,
  128. })
  129. expect(buildSessionWhere([{ kind: 'cwd', values: ['/a'] }])).toEqual({
  130. sql: '(cwd IN (?))',
  131. params: ['/a'],
  132. predicateCount: 1,
  133. })
  134. expect(buildSessionWhere([{ kind: 'parent', values: [SessionId('p'), null] }])).toEqual({
  135. sql: '(parent_session IN (?) OR parent_session IS NULL)',
  136. params: [SessionId('p')],
  137. predicateCount: 1,
  138. })
  139. expect(buildSessionWhere([
  140. { kind: 'created-at', from: 1, to: 2 },
  141. { kind: 'availability', values: [] },
  142. { kind: 'availability', values: ['live', 'live'] },
  143. { kind: 'availability', values: ['live', 'persisted'] },
  144. ])).toEqual({
  145. sql: 'CAST(created_at AS INTEGER) >= ? AND CAST(created_at AS INTEGER) <= ? AND 0 AND live = 1',
  146. params: [1, 2],
  147. predicateCount: 4,
  148. })
  149. expect(buildSessionWhere([{ kind: 'created-at' }])).toEqual({
  150. sql: '',
  151. params: [],
  152. predicateCount: 0,
  153. })
  154. })
  155. it('compiles every event clause and empty lists', () => {
  156. expect(buildEventWhere([
  157. { kind: 'seq', from: 1 },
  158. { kind: 'time', to: 9 },
  159. { kind: 'type', values: ['user/message'] },
  160. { kind: 'surface', values: ['current', 'log-only'] },
  161. ])).toEqual({
  162. sql: 'CAST(seq AS INTEGER) >= ? AND CAST(time AS INTEGER) <= ? AND type IN (?) AND surface IN (?, ?)',
  163. params: [1, 9, 'user/message', 'current', 'log-only'],
  164. predicateCount: 4,
  165. })
  166. expect(buildEventWhere([
  167. { kind: 'type', values: [] },
  168. { kind: 'surface', values: [] },
  169. ])).toEqual({ sql: '0 AND 0', params: [], predicateCount: 2 })
  170. })
  171. it('rejects predicate builders above the supported FTS5 outer budget', () => {
  172. const filters = Array.from(
  173. { length: SQLITE_FTS5_OUTER_PREDICATE_LIMIT },
  174. () => ({ kind: 'id' as const, values: [SessionId('safe')] }),
  175. )
  176. expect(buildSessionWhere(filters).predicateCount).toBe(SQLITE_FTS5_OUTER_PREDICATE_LIMIT)
  177. expect(() => buildSessionWhere([
  178. ...filters,
  179. { kind: 'id', values: [SessionId('over')] },
  180. ])).toThrow(expectCode('SESSION_QUERY_INVALID_FILTER'))
  181. })
  182. it('rejects runtime-unknown filter discriminants in both SQL builders', () => {
  183. expect(() => buildSessionWhere([{ kind: 'future' } as never]))
  184. .toThrow(expectCode('SESSION_QUERY_INVALID_FILTER'))
  185. expect(() => buildEventWhere([{ kind: 'future' } as never]))
  186. .toThrow(expectCode('SESSION_QUERY_INVALID_FILTER'))
  187. expect(() => buildSessionWhere([{ kind: 'availability', values: ['future'] } as never]))
  188. .toThrow(expectCode('SESSION_QUERY_INVALID_FILTER'))
  189. expect(() => buildSessionWhere([{} as never]))
  190. .toThrow(expectCode('SESSION_QUERY_INVALID_FILTER'))
  191. })
  192. })
  193. describe('SQLite query identity and presentation', () => {
  194. it('quotes all caller MATCH syntax as data', () => {
  195. expect(quoteFtsData('say "needle" OR *')).toBe('"say ""needle"" OR *"')
  196. })
  197. it('canonicalizes request and filter ordering in both scopes', () => {
  198. const sessionA: NormalizedSessionRequest = {
  199. query: 'needle',
  200. limit: 2,
  201. sessionFilters: [
  202. { kind: 'cwd', values: ['/b', '/a'] },
  203. { kind: 'parent', values: [null, SessionId('p')] },
  204. { kind: 'id', values: [SessionId('same'), SessionId('same')] },
  205. { kind: 'created-at', from: 1 },
  206. ],
  207. eventFilters: [{ kind: 'time', to: 9 }],
  208. }
  209. const sessionB: NormalizedSessionRequest = {
  210. query: 'needle',
  211. limit: 2,
  212. sessionFilters: [
  213. { kind: 'created-at', from: 1 },
  214. { kind: 'id', values: [SessionId('same'), SessionId('same')] },
  215. { kind: 'parent', values: [SessionId('p'), null] },
  216. { kind: 'cwd', values: ['/a', '/b'] },
  217. ],
  218. eventFilters: [{ kind: 'time', to: 9 }],
  219. }
  220. expect(requestFingerprint(sessionA)).toBe(requestFingerprint(sessionB))
  221. const eventA: NormalizedEventRequest = {
  222. sessionId: SessionId('s'),
  223. query: 'needle',
  224. limit: 2,
  225. filters: [{ kind: 'seq' }, { kind: 'surface', values: ['shadowed', 'current'] }],
  226. }
  227. const eventB: NormalizedEventRequest = {
  228. sessionId: SessionId('s'),
  229. query: 'needle',
  230. limit: 2,
  231. filters: [{ kind: 'surface', values: ['current', 'shadowed'] }, { kind: 'seq' }],
  232. }
  233. expect(requestFingerprint(eventA)).toBe(requestFingerprint(eventB))
  234. expect(requestFingerprint(eventA)).not.toBe(requestFingerprint({ ...eventB, sessionId: SessionId('other') }))
  235. })
  236. it('normalizes, bounds, and positions snippets by Unicode code point', () => {
  237. expect(makeSnippet(' short\ntext ', 20)).toBe('short text')
  238. expect(makeSnippet(`abcde${FTS_HIGHLIGHT_START}f${FTS_HIGHLIGHT_END}`, 1)).toBe('…')
  239. expect(makeSnippet('abcdefghij', 5)).toBe('abcd…')
  240. expect(makeSnippet(`ab${FTS_HIGHLIGHT_START}c${FTS_HIGHLIGHT_END}defghij`, 5)).toBe('…bcd…')
  241. expect(makeSnippet(`ab${FTS_HIGHLIGHT_START}c${FTS_HIGHLIGHT_END}defghij`, 3)).toBe('…c…')
  242. expect(makeSnippet(`abcde${FTS_HIGHLIGHT_START}f${FTS_HIGHLIGHT_END}`, 2)).toBe('…f')
  243. expect(makeSnippet(`abcde${FTS_HIGHLIGHT_START}f${FTS_HIGHLIGHT_END}`, 5)).toBe('…cdef')
  244. expect(makeSnippet(` x—${FTS_HIGHLIGHT_START}café${FTS_HIGHLIGHT_END}\n y `, 20))
  245. .toBe('x—café y')
  246. })
  247. })