~bigbes/lethe

f0f651bfc74681988f56bd610074e1dce6dbee1c — Eugene Blikh 23 days ago 7b17e7c
feat: add search data layer — adapter, highlight helper, hook, and tests
M web/src/api/adapters.ts => web/src/api/adapters.ts +51 -0
@@ 204,6 204,57 @@ export function adaptStats(d: StatsDTO): Stats {
  }
}

// ── Search ────────────────────────────────────────────────────────────────────

export interface SearchRowDTO {
  owner: string
  tool: string
  host: string
  session_id: string
  working_dir?: string
  turn_id: string
  seq: number
  role: string
  timestamp: number
  rank: number
  match_source: string
  snippet: string
}

export interface SearchRow {
  tool: string
  host: string
  sessionId: string
  cwd: string
  turnId: string
  role: string
  timestamp: number
  rank: number
  matchSource: string
  snippet: string
}

export interface SearchResult {
  results: SearchRow[]
  limit: number
  nextCursor?: string
}

export function adaptSearchRow(d: SearchRowDTO): SearchRow {
  return {
    tool: d.tool,
    host: d.host,
    sessionId: d.session_id,
    cwd: d.working_dir ?? '',
    turnId: d.turn_id,
    role: d.role,
    timestamp: d.timestamp,
    rank: d.rank,
    matchSource: d.match_source,
    snippet: d.snippet,
  }
}

// ── Saved searches ──────────────────────────────────────────────────────────

export interface SavedSearchDTO {

A web/src/features/search/highlightSnippet.ts => web/src/features/search/highlightSnippet.ts +23 -0
@@ 0,0 1,23 @@
import React from 'react'

const MARKER_RUNES = /\x02|\x03/g

/**
 * Splits a snippet string on \x02/\x03 marker runes and interleaves plain text
 * with `<mark>` elements for matched segments.
 *
 * Respects IV2 — no dangerouslySetInnerHTML, marker-rune split with React text
 * nodes only, safe against XSS.
 */
export function highlightSnippet(snippet: string): (string | React.JSX.Element)[] {
  const parts = snippet.split(MARKER_RUNES)
  const out: (string | React.JSX.Element)[] = []
  let inMatch = false
  for (const p of parts) {
    if (p === '') continue
    if (inMatch) out.push(React.createElement('mark', { key: out.length }, p))
    else out.push(p)
    inMatch = !inMatch
  }
  return out
}

A web/src/features/search/useSearch.test.ts => web/src/features/search/useSearch.test.ts +166 -0
@@ 0,0 1,166 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { renderHook, act, waitFor } from '@testing-library/react'
import React from 'react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { useSearch } from './useSearch'
import type { SearchRowDTO } from '../../api/adapters'
import type { SearchFilters } from './useSearch'

// ── Mock apiFetch ─────────────────────────────────────────────────────────────

const mockApiFetch = vi.fn()

vi.mock('../../api/client', () => ({
  apiFetch: (...args: unknown[]) => mockApiFetch(...args),
  apiFetchVoid: vi.fn().mockResolvedValue(undefined),
  AuthError: class AuthError extends Error {
    name = 'AuthError'
    constructor(message: string) { super(message) }
  },
  APIError: class APIError extends Error {
    name = 'APIError'
    status: number
    code: string
    constructor(message: string, status: number, code: string) {
      super(message)
      this.status = status
      this.code = code
    }
  },
}))

// ── Test wrapper ──────────────────────────────────────────────────────────────

function makeWrapper(queryClient: QueryClient) {
  return function Wrapper({ children }: { children: React.ReactNode }) {
    return React.createElement(QueryClientProvider, { client: queryClient }, children)
  }
}

// ── Helpers ───────────────────────────────────────────────────────────────────

function makeRowDTO(overrides?: Partial<SearchRowDTO>): SearchRowDTO {
  return {
    owner: '',
    tool: 'opencode',
    host: 'laptop',
    session_id: 'ses-1',
    working_dir: '/home/user',
    turn_id: 'turn-1',
    seq: 1,
    role: 'user',
    timestamp: 1000000,
    rank: 0.5,
    match_source: 'message',
    snippet: 'hello \x02world\x03',
    ...overrides,
  }
}

// ── Tests ─────────────────────────────────────────────────────────────────────

describe('useSearch', () => {
  let queryClient: QueryClient

  beforeEach(() => {
    queryClient = new QueryClient({
      defaultOptions: {
        queries: { retry: false },
        mutations: { retry: false },
      },
    })
    mockApiFetch.mockReset()
  })

  it('constructs correct URL from filters (q, tool, host → query string)', async () => {
    mockApiFetch.mockResolvedValueOnce({ results: [makeRowDTO()], limit: 20 })

    const filters: SearchFilters = { q: 'hello', tool: 'opencode', host: 'laptop' }
    const wrapper = makeWrapper(queryClient)
    renderHook(() => useSearch(filters, true), { wrapper })

    await waitFor(() => {
      expect(mockApiFetch).toHaveBeenCalledTimes(1)
    })

    const url = mockApiFetch.mock.calls[0][0] as string
    expect(url).toContain('/api/v1/search')
    expect(url).toContain('q=hello')
    expect(url).toContain('tool=opencode')
    expect(url).toContain('host=laptop')
  })

  it('getNextPageParam returns next cursor from last page', async () => {
    mockApiFetch
      .mockResolvedValueOnce({ results: [makeRowDTO()], limit: 20, next_cursor: 'cursor-2' })
      .mockResolvedValueOnce({ results: [makeRowDTO()], limit: 20 })

    const filters: SearchFilters = { q: 'hello' }
    const wrapper = makeWrapper(queryClient)
    const { result } = renderHook(() => useSearch(filters, true), { wrapper })

    // Wait for first page
    await waitFor(() => expect(result.current.isLoading).toBe(false))

    // Fetch next page
    await act(() => result.current.fetchNextPage())

    await waitFor(() => {
      expect(mockApiFetch).toHaveBeenCalledTimes(2)
    })

    // Second call should have cursor param
    const secondUrl = mockApiFetch.mock.calls[1][0] as string
    expect(secondUrl).toContain('cursor=cursor-2')
  })

  it('returns flat results array from all pages', async () => {
    mockApiFetch
      .mockResolvedValueOnce({ results: [makeRowDTO({ snippet: 'a' })], limit: 20, next_cursor: 'cursor-2' })
      .mockResolvedValueOnce({ results: [makeRowDTO({ snippet: 'b' })], limit: 20 })

    const filters: SearchFilters = { q: 'hello' }
    const wrapper = makeWrapper(queryClient)
    const { result } = renderHook(() => useSearch(filters, true), { wrapper })

    await waitFor(() => expect(result.current.isLoading).toBe(false))

    expect(result.current.results).toHaveLength(1)

    await act(() => result.current.fetchNextPage())
    await waitFor(() => {
      expect(result.current.results).toHaveLength(2)
    })
  })

  it('returns empty results when q is empty string', async () => {
    const filters: SearchFilters = { q: '' }
    const wrapper = makeWrapper(queryClient)
    const { result } = renderHook(() => useSearch(filters, true), { wrapper })

    expect(result.current.results).toEqual([])
    expect(mockApiFetch).not.toHaveBeenCalled()
  })

  it('does not fetch when disabled', async () => {
    const filters: SearchFilters = { q: 'hello' }
    const wrapper = makeWrapper(queryClient)
    renderHook(() => useSearch(filters, false), { wrapper })

    // Give any potential fetch a moment
    await new Promise(r => setTimeout(r, 50))
    expect(mockApiFetch).not.toHaveBeenCalled()
  })

  it('propagates fetch errors', async () => {
    mockApiFetch.mockRejectedValueOnce(new Error('network error'))

    const filters: SearchFilters = { q: 'hello' }
    const wrapper = makeWrapper(queryClient)
    const { result } = renderHook(() => useSearch(filters, true), { wrapper })

    await waitFor(() => expect(result.current.isLoading).toBe(false))
    expect(result.current.error).toBeDefined()
    expect(result.current.error).toBeInstanceOf(Error)
  })
})

A web/src/features/search/useSearch.ts => web/src/features/search/useSearch.ts +74 -0
@@ 0,0 1,74 @@
import { useInfiniteQuery } from '@tanstack/react-query'
import { apiFetch } from '../../api/client'
import { adaptSearchRow } from '../../api/adapters'
import type { SearchRow, SearchRowDTO } from '../../api/adapters'

interface SearchResponseDTO {
  results: SearchRowDTO[]
  limit: number
  next_cursor?: string
}

export interface SearchResult {
  results: SearchRow[]
  limit: number
  nextCursor?: string
}

export interface SearchFilters {
  q: string
  tool?: string
  host?: string
}

interface UseSearchReturn {
  results: SearchRow[]
  fetchNextPage: () => void
  hasNextPage: boolean
  isFetchingNextPage: boolean
  isLoading: boolean
  error: Error | null
}

/**
 * Hook for cursor-paginated full-text search against the search API.
 *
 * - Query key includes filters so changing any filter triggers a fresh fetch.
 * - Empty `q` short-circuits to an empty result set without issuing a request.
 * - Cursor pagination appends results (never replaces) when fetchNextPage is called.
 *
 * Respects: IV3 (cursor pagination), AS1 (cursor pagination is stable).
 */
export function useSearch(filters: SearchFilters, enabled: boolean): UseSearchReturn {
  const query = useInfiniteQuery<SearchResponseDTO, Error, SearchResult>({
    queryKey: ['search', filters],
    queryFn: async ({ pageParam }) => {
      const params = new URLSearchParams()
      params.set('q', filters.q)
      if (filters.tool) params.set('tool', filters.tool)
      if (filters.host) params.set('host', filters.host)
      if (pageParam) params.set('cursor', pageParam as string)
      return apiFetch<SearchResponseDTO>(`/api/v1/search?${params.toString()}`)
    },
    initialPageParam: undefined as string | undefined,
    getNextPageParam: (lastPage) => lastPage.next_cursor ?? undefined,
    enabled: enabled && filters.q.length > 0,
    staleTime: 0,
    select: (data) => ({
      pages: data.pages,
      pageParams: data.pageParams,
      results: data.pages.flatMap(p => p.results.map(adaptSearchRow)),
      limit: data.pages[data.pages.length - 1]?.limit ?? 0,
      nextCursor: data.pages[data.pages.length - 1]?.next_cursor,
    }),
  })

  return {
    results: query.data?.results ?? [],
    fetchNextPage: query.fetchNextPage,
    hasNextPage: query.hasNextPage ?? false,
    isFetchingNextPage: query.isFetchingNextPage,
    isLoading: query.isLoading,
    error: query.error,
  }
}