query.spec.ts 9.3 KB

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