mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 01:15:03 +03:00
feat: Add edge function and hooks for search V2 (#51103)
This commit is contained in:
1 parent
8696762b4b
commit
99103b3571
10 files changed
+409
-10
No files matched your search
@@ -0,0 +1,5 @@
|
||||
export const corsHeaders = {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'content-type',
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { supabaseSearchV2 } from '~/scripts/search_v2/client'
|
||||
import { type NextRequest } from 'next/server'
|
||||
|
||||
import { corsHeaders } from './cors'
|
||||
|
||||
export const runtime = 'edge'
|
||||
|
||||
export async function OPTIONS() {
|
||||
return new Response(null, { headers: corsHeaders })
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const query = searchParams.get('q')?.trim()
|
||||
const limit = Number(searchParams.get('limit')) || 10
|
||||
|
||||
if (!query) {
|
||||
return Response.json({ error: 'Missing q parameter' }, { status: 400, headers: corsHeaders })
|
||||
}
|
||||
|
||||
const { data, error } = await supabaseSearchV2().rpc('search_docs', {
|
||||
query_text: query,
|
||||
match_limit: limit,
|
||||
})
|
||||
|
||||
if (error) {
|
||||
console.error('Error running docs search v2:', error)
|
||||
return Response.json({ error: error.message }, { status: 500, headers: corsHeaders })
|
||||
}
|
||||
|
||||
return Response.json(data, { headers: corsHeaders })
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { load } from 'cheerio'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { formatHeadingPath, highlightMatches } from './SearchV2.utils'
|
||||
|
||||
describe('formatHeadingPath', () => {
|
||||
it('joins multiple headings with " > "', () => {
|
||||
expect(formatHeadingPath(['Title 1', 'Title 2'])).toBe('Title 1 > Title 2')
|
||||
})
|
||||
|
||||
it('returns a single-element path unchanged', () => {
|
||||
expect(formatHeadingPath(['Title 1'])).toBe('Title 1')
|
||||
})
|
||||
|
||||
it('returns an empty string for an empty path', () => {
|
||||
expect(formatHeadingPath([])).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
function renderHighlight(text: string, query: string) {
|
||||
const result = highlightMatches(text, query)
|
||||
if (typeof result === 'string') return { html: result, strongTexts: [] as string[] }
|
||||
|
||||
const html = renderToStaticMarkup(<>{result}</>)
|
||||
const $ = load(html)
|
||||
return {
|
||||
html: $.root().text(),
|
||||
strongTexts: $('strong')
|
||||
.map((_, el) => $(el).text())
|
||||
.get(),
|
||||
}
|
||||
}
|
||||
|
||||
describe('highlightMatches', () => {
|
||||
it('highlights a single case-insensitive partial match', () => {
|
||||
const result = highlightMatches('Bring your own MCP', 'mcp server')
|
||||
expect(typeof result).not.toBe('string')
|
||||
|
||||
const { strongTexts, html } = renderHighlight('Bring your own MCP', 'mcp server')
|
||||
expect(strongTexts).toEqual(['MCP'])
|
||||
expect(html).toBe('Bring your own MCP')
|
||||
})
|
||||
|
||||
it('highlights multiple non-overlapping token matches independently', () => {
|
||||
const { strongTexts } = renderHighlight('MCP servers for your server', 'mcp server')
|
||||
expect(strongTexts).toEqual(['MCP', 'server', 'server'])
|
||||
})
|
||||
|
||||
it('merges overlapping/adjacent matches into a single run', () => {
|
||||
const { strongTexts } = renderHighlight('server', 'server serv')
|
||||
expect(strongTexts).toEqual(['server'])
|
||||
})
|
||||
|
||||
it('returns the original string unchanged when there is no match', () => {
|
||||
const result = highlightMatches('Bring your own MCP', 'unrelated')
|
||||
expect(result).toBe('Bring your own MCP')
|
||||
})
|
||||
|
||||
it('is case-insensitive but preserves the original casing of the matched text', () => {
|
||||
const { strongTexts } = renderHighlight('Bring your own MCP', 'MCP')
|
||||
expect(strongTexts).toEqual(['MCP'])
|
||||
})
|
||||
|
||||
it('returns the text unchanged for an empty or whitespace-only query', () => {
|
||||
expect(highlightMatches('Bring your own MCP', '')).toBe('Bring your own MCP')
|
||||
expect(highlightMatches('Bring your own MCP', ' ')).toBe('Bring your own MCP')
|
||||
})
|
||||
|
||||
it('excludes common prepositions/articles/conjunctions from highlighting', () => {
|
||||
const { strongTexts } = renderHighlight('The best MCP server for you', 'the mcp server')
|
||||
expect(strongTexts).toEqual(['MCP', 'server'])
|
||||
})
|
||||
|
||||
it('returns the text unchanged when the query is made up entirely of ignored words', () => {
|
||||
const result = highlightMatches('The best MCP server', 'the of')
|
||||
expect(result).toBe('The best MCP server')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,86 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
/** Common English prepositions/articles/conjunctions, excluded from highlighting so a query like "the mcp server" doesn't bold "the". */
|
||||
const IGNORED_WORDS = new Set([
|
||||
'a',
|
||||
'an',
|
||||
'the',
|
||||
'and',
|
||||
'or',
|
||||
'but',
|
||||
'of',
|
||||
'in',
|
||||
'on',
|
||||
'at',
|
||||
'to',
|
||||
'for',
|
||||
'from',
|
||||
'by',
|
||||
'with',
|
||||
'as',
|
||||
])
|
||||
|
||||
/** Join a heading breadcrumb into a single display string, e.g. ['Title 1', 'Title 2'] -> 'Title 1 > Title 2'. */
|
||||
function formatHeadingPath(headingPath: string[]): string {
|
||||
return headingPath.join(' > ')
|
||||
}
|
||||
|
||||
/** Case-insensitive, whitespace-split match ranges for every occurrence of every query token in text. */
|
||||
function getMatchRanges(text: string, query: string): Array<[number, number]> {
|
||||
const tokens = query
|
||||
.split(/\s+/)
|
||||
.map((token) => token.trim())
|
||||
.filter((token) => token.length > 0 && !IGNORED_WORDS.has(token.toLowerCase()))
|
||||
if (tokens.length === 0) return []
|
||||
|
||||
const lowerText = text.toLowerCase()
|
||||
const ranges: Array<[number, number]> = []
|
||||
|
||||
for (const token of tokens) {
|
||||
const lowerToken = token.toLowerCase()
|
||||
let fromIndex = 0
|
||||
while (fromIndex <= lowerText.length) {
|
||||
const idx = lowerText.indexOf(lowerToken, fromIndex)
|
||||
if (idx === -1) break
|
||||
ranges.push([idx, idx + lowerToken.length])
|
||||
fromIndex = idx + lowerToken.length
|
||||
}
|
||||
}
|
||||
|
||||
if (ranges.length === 0) return []
|
||||
|
||||
ranges.sort((a, b) => a[0] - b[0])
|
||||
const merged: Array<[number, number]> = [ranges[0]]
|
||||
for (const [start, end] of ranges.slice(1)) {
|
||||
const last = merged[merged.length - 1]
|
||||
if (start <= last[1]) {
|
||||
last[1] = Math.max(last[1], end)
|
||||
} else {
|
||||
merged.push([start, end])
|
||||
}
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
/**
|
||||
* Highlight every case-insensitive, per-word partial match of `query` inside `text`.
|
||||
* Returns the plain string when there's no match, otherwise a fragment with matches
|
||||
* wrapped in <strong>, preserving the original casing of `text`.
|
||||
*/
|
||||
function highlightMatches(text: string, query: string): ReactNode {
|
||||
const ranges = getMatchRanges(text, query)
|
||||
if (ranges.length === 0) return text
|
||||
|
||||
const nodes: ReactNode[] = []
|
||||
let cursor = 0
|
||||
ranges.forEach(([start, end], i) => {
|
||||
if (start > cursor) nodes.push(text.slice(cursor, start))
|
||||
nodes.push(<strong key={i}>{text.slice(start, end)}</strong>)
|
||||
cursor = end
|
||||
})
|
||||
if (cursor < text.length) nodes.push(text.slice(cursor))
|
||||
|
||||
return <>{nodes}</>
|
||||
}
|
||||
|
||||
export { formatHeadingPath, highlightMatches }
|
||||
@@ -1,10 +1,10 @@
|
||||
'use client'
|
||||
|
||||
import { useDocsSearch, type DocsSearchResult } from 'common'
|
||||
import { useDocsSearchV2, type DocsSearchV2Result } from 'common'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { VisuallyHidden } from 'radix-ui'
|
||||
import { useEffect } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
@@ -18,6 +18,8 @@ import {
|
||||
DialogTitle,
|
||||
} from 'ui'
|
||||
|
||||
import { formatHeadingPath, highlightMatches } from './SearchV2.utils'
|
||||
|
||||
interface SearchV2DialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
@@ -25,14 +27,25 @@ interface SearchV2DialogProps {
|
||||
|
||||
export function SearchV2Dialog({ open, onOpenChange }: SearchV2DialogProps) {
|
||||
const router = useRouter()
|
||||
const { searchState, handleDocsSearchDebounced, resetSearch } = useDocsSearch()
|
||||
const { searchState, handleDocsSearchDebounced, resetSearch } = useDocsSearchV2()
|
||||
const [highlightQuery, setHighlightQuery] = useState('')
|
||||
|
||||
// Clear stale results once the dialog closes
|
||||
useEffect(() => {
|
||||
if (!open) resetSearch()
|
||||
}, [open, resetSearch])
|
||||
|
||||
const results: DocsSearchResult[] =
|
||||
// Only update the highlighted query once a new result set actually lands, so highlights
|
||||
// don't shift on every keystroke while the debounced search is still in flight.
|
||||
useEffect(() => {
|
||||
if (searchState.status === 'results' || searchState.status === 'noResults') {
|
||||
setHighlightQuery(searchState.query)
|
||||
} else if (searchState.status === 'initial') {
|
||||
setHighlightQuery('')
|
||||
}
|
||||
}, [searchState])
|
||||
|
||||
const results: DocsSearchV2Result[] =
|
||||
'results' in searchState
|
||||
? searchState.results
|
||||
: 'staleResults' in searchState
|
||||
@@ -107,16 +120,18 @@ export function SearchV2Dialog({ open, onOpenChange }: SearchV2DialogProps) {
|
||||
<CommandGroup heading="Results" forceMount>
|
||||
{results.map((page) => (
|
||||
<CommandItem
|
||||
key={page.id}
|
||||
value={String(page.id)}
|
||||
key={page.path}
|
||||
value={page.path}
|
||||
forceMount
|
||||
onSelect={() => handleSelect(page.path)}
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm">{page.title}</span>
|
||||
{(page.description || page.subtitle) && (
|
||||
<span className="text-sm">
|
||||
{highlightMatches(formatHeadingPath(page.headingPath), highlightQuery)}
|
||||
</span>
|
||||
{page.excerpt && (
|
||||
<span className="text-xs text-foreground-muted">
|
||||
{page.description || page.subtitle}
|
||||
{highlightMatches(page.excerpt, highlightQuery)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import { useFeatureFlags, useSearchParamsShallow } from 'common'
|
||||
|
||||
import { SEARCH_V2_FLAG, type SearchV2Variant } from './constants'
|
||||
import { IS_PRODUCTION } from '@/lib/constants'
|
||||
|
||||
const VARIANTS: SearchV2Variant[] = ['control', 'search-v2-active']
|
||||
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { createClient, type SupabaseClient } from '@supabase/supabase-js'
|
||||
|
||||
// Search V2 lives in its own Supabase project (see ./sql/setup.sql), separate from the main
|
||||
// docs content DB `lib/supabase.ts` connects to — so it needs its own client and its own
|
||||
// hand-written Database type (there's no generated schema for this project).
|
||||
export type Database = {
|
||||
public: {
|
||||
Tables: Record<string, never>
|
||||
Views: Record<string, never>
|
||||
Functions: {
|
||||
search_docs: {
|
||||
Args: { query_text: string; match_limit?: number }
|
||||
Returns: Array<{
|
||||
slug: string
|
||||
page_title: string
|
||||
heading: string
|
||||
heading_level: number
|
||||
heading_path: string[]
|
||||
excerpt: string
|
||||
score: number
|
||||
}>
|
||||
}
|
||||
}
|
||||
Enums: Record<string, never>
|
||||
CompositeTypes: Record<string, never>
|
||||
}
|
||||
}
|
||||
|
||||
let _supabaseSearchV2: SupabaseClient<Database>
|
||||
|
||||
export function supabaseSearchV2() {
|
||||
if (!_supabaseSearchV2) {
|
||||
_supabaseSearchV2 = createClient<Database>(
|
||||
`https://${process.env.SEARCH_V2_SUPABASE_PROJECT_ID}.supabase.co`,
|
||||
process.env.SEARCH_V2_SUPABASE_SECRET_KEY!
|
||||
)
|
||||
}
|
||||
|
||||
return _supabaseSearchV2
|
||||
}
|
||||
@@ -82,6 +82,8 @@
|
||||
"LOGFLARE_INGESTION_API_KEY",
|
||||
"LOGFLARE_SOURCE_TOKEN",
|
||||
"OPENAI_API_KEY",
|
||||
"SEARCH_V2_SUPABASE_PROJECT_ID",
|
||||
"SEARCH_V2_SUPABASE_SECRET_KEY",
|
||||
"SITE_NAME",
|
||||
"SUPABASE_SECRET_KEY",
|
||||
],
|
||||
|
||||
@@ -7,6 +7,7 @@ export * from './useCopy'
|
||||
export * from './useCopyMarkdownFromUrl'
|
||||
export * from './useDebounce'
|
||||
export * from './useDocsSearch'
|
||||
export * from './useDocsSearchV2'
|
||||
export * from './useDragToClose'
|
||||
export * from './useEffectEvent'
|
||||
export * from './useIsomorphicLayoutEffect'
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
'use client'
|
||||
|
||||
import { compact, debounce } from 'lodash'
|
||||
import { useCallback, useMemo, useReducer, useRef } from 'react'
|
||||
|
||||
// This app's own base path, set only for apps deployed under a path prefix (docs' is '/docs').
|
||||
const BASE_PATH = process.env.NEXT_PUBLIC_BASE_PATH ?? ''
|
||||
// Public URL of the docs deployment, which hosts the search API routes.
|
||||
const DOCS_URL = process.env.NEXT_PUBLIC_DOCS_URL || 'https://supabase.com/docs'
|
||||
// From inside the docs app itself, call our own routes relatively; from studio/www, call docs directly.
|
||||
const SEARCH_API_BASE = BASE_PATH === '/docs' ? BASE_PATH : DOCS_URL
|
||||
|
||||
interface DocsSearchV2Result {
|
||||
path: string
|
||||
title: string
|
||||
heading: string
|
||||
excerpt: string
|
||||
headingPath: string[]
|
||||
score: number
|
||||
}
|
||||
|
||||
type SearchState =
|
||||
| { status: 'initial'; key: number }
|
||||
| { status: 'loading'; key: number; staleResults: DocsSearchV2Result[] }
|
||||
| { status: 'results'; key: number; results: DocsSearchV2Result[]; query: string }
|
||||
| { status: 'noResults'; key: number; query: string }
|
||||
| { status: 'error'; key: number; message: string }
|
||||
|
||||
type Action =
|
||||
| { type: 'resultsReturned'; key: number; results: DocsSearchV2Result[]; query: string }
|
||||
| { type: 'newSearchDispatched'; key: number }
|
||||
| { type: 'reset'; key: number }
|
||||
| { type: 'errored'; key: number; message: string }
|
||||
|
||||
function reshapeResult(row: unknown): DocsSearchV2Result | null {
|
||||
if (typeof row !== 'object' || row === null) return null
|
||||
if (
|
||||
!(
|
||||
'slug' in row &&
|
||||
'page_title' in row &&
|
||||
'heading' in row &&
|
||||
'excerpt' in row &&
|
||||
'heading_path' in row &&
|
||||
'score' in row
|
||||
)
|
||||
)
|
||||
return null
|
||||
|
||||
const slug = row.slug as string
|
||||
return {
|
||||
// Next's router already prepends the app's basePath ('/docs'), so don't add it here.
|
||||
path: slug ? `/${slug}` : '/',
|
||||
title: row.page_title as string,
|
||||
heading: row.heading as string,
|
||||
excerpt: row.excerpt as string,
|
||||
headingPath: row.heading_path as string[],
|
||||
score: row.score as number,
|
||||
}
|
||||
}
|
||||
|
||||
function reducer(state: SearchState, action: Action): SearchState {
|
||||
// Ignore responses from outdated async functions
|
||||
if (state.key > action.key) {
|
||||
return state
|
||||
}
|
||||
switch (action.type) {
|
||||
case 'resultsReturned':
|
||||
return action.results.length
|
||||
? { status: 'results', key: action.key, results: action.results, query: action.query }
|
||||
: { status: 'noResults', key: action.key, query: action.query }
|
||||
case 'newSearchDispatched':
|
||||
return {
|
||||
status: 'loading',
|
||||
key: action.key,
|
||||
staleResults:
|
||||
'results' in state ? state.results : 'staleResults' in state ? state.staleResults : [],
|
||||
}
|
||||
case 'reset':
|
||||
return { status: 'initial', key: action.key }
|
||||
case 'errored':
|
||||
return { status: 'error', key: action.key, message: action.message }
|
||||
default:
|
||||
return state
|
||||
}
|
||||
}
|
||||
|
||||
const useDocsSearchV2 = () => {
|
||||
const [state, dispatch] = useReducer(reducer, { status: 'initial', key: 0 })
|
||||
const key = useRef(0)
|
||||
|
||||
const handleSearch = useCallback(async (query: string) => {
|
||||
key.current += 1
|
||||
const localKey = key.current
|
||||
dispatch({ type: 'newSearchDispatched', key: localKey })
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({ q: query.trim() })
|
||||
const response = await fetch(`${SEARCH_API_BASE}/api/search_v2?${params}`)
|
||||
const data = await response.json()
|
||||
if (!Array.isArray(data)) {
|
||||
throw new Error(data?.error ?? "didn't get expected results array")
|
||||
}
|
||||
dispatch({
|
||||
type: 'resultsReturned',
|
||||
key: localKey,
|
||||
results: compact(data.map(reshapeResult)),
|
||||
query: query.trim(),
|
||||
})
|
||||
} catch (error) {
|
||||
console.error(`[ERROR] Error fetching docs search v2 results: ${error}`)
|
||||
dispatch({
|
||||
type: 'errored',
|
||||
key: localKey,
|
||||
message: error instanceof Error ? error.message : '',
|
||||
})
|
||||
}
|
||||
}, [])
|
||||
|
||||
const debouncedSearch = useMemo(() => debounce(handleSearch, 250), [handleSearch])
|
||||
|
||||
const debounceCancel = useCallback(() => {
|
||||
debouncedSearch.cancel()
|
||||
}, [debouncedSearch])
|
||||
|
||||
const resetSearch = useCallback(() => {
|
||||
debounceCancel()
|
||||
key.current += 1
|
||||
dispatch({ type: 'reset', key: key.current })
|
||||
}, [debounceCancel])
|
||||
|
||||
return {
|
||||
searchState: state,
|
||||
handleDocsSearch: handleSearch,
|
||||
handleDocsSearchDebounced: debouncedSearch,
|
||||
resetSearch,
|
||||
}
|
||||
}
|
||||
|
||||
export { useDocsSearchV2 }
|
||||
export type { DocsSearchV2Result }
|
||||
Reference in new issue
Block a user