import { OpenAPIHono, createRoute } from '@hono/zod-openapi'; import type { AppBindings } from '../env.js'; import { CombinedSearchQuerySchema, CombinedSearchSuccessSchema, SearchErrorSchema, type BookResult, } from '../schemas/search.js'; import { createSuccessResponse, createErrorResponse, ErrorCode, } from '../schemas/response.js'; import { detectQueryType, type DetectionResult } from '../lib/query-detector.js'; import { buildCombinedCacheKey, getCacheTTL, } from '../lib/cache-helpers.js'; // ================================================================================= // Search Execution Functions // ================================================================================= /** * Search by ISBN using edition_isbns table (REQUIRED by CLAUDE.md) */ async function searchByISBN( sql: any, isbn: string, limit: number, offset: number ): Promise<{ data: any[]; total: number; totalEstimated?: boolean }> { const results: any[] = await sql` SELECT e.isbn AS isbn, e.title, e.publication_date AS publish_date, e.publisher AS publishers, e.page_count AS pages, e.format AS binding, w.title AS work_title, CONCAT('https://openlibrary.org/books/', e.openlibrary_edition_id) AS openlibrary_edition_url, CONCAT('https://openlibrary.org/works/', e.work_key) AS openlibrary_work_url, e.cover_url_large AS cover_url, e.cover_source, COALESCE( json_agg( DISTINCT jsonb_build_object( 'name', a.name, 'key', a.author_key, 'openlibrary', CONCAT('https://openlibrary.org', a.author_key), 'gender', a.gender, 'nationality', a.nationality, 'birth_year', a.birth_year, 'death_year', a.death_year, 'bio', a.bio, 'wikidata_id', a.wikidata_id, 'image', a.author_photo_url ) ) FILTER (WHERE a.author_key IS NOT NULL), '[]'::json ) AS authors FROM edition_isbns ei JOIN enriched_editions e ON ei.isbn = e.isbn LEFT JOIN enriched_works w ON e.work_key = w.work_key LEFT JOIN author_works aw ON w.work_key = aw.work_key LEFT JOIN enriched_authors a ON aw.author_key = a.author_key WHERE ei.isbn = ${isbn} GROUP BY e.isbn, e.title, e.publication_date, e.publisher, e.page_count, e.format, e.cover_url_large, e.cover_source, e.openlibrary_edition_id, e.work_key, w.title LIMIT ${limit} OFFSET ${offset} `; return { data: results, total: results.length > 0 ? 1 : 0, // ISBN is unique }; } /** * Search by author name using enriched_authors table */ async function searchByAuthor( sql: any, name: string, limit: number, offset: number ): Promise<{ data: any[]; total: number; totalEstimated?: boolean }> { // OPTIMIZATION: Use LIMIT limit + 1 strategy instead of separate COUNT(*) query const dataResult: any[] = await sql` SELECT e.isbn AS isbn, e.title, e.publication_date AS publish_date, e.publisher AS publishers, e.page_count AS pages, e.format AS binding, w.title AS work_title, CONCAT('https://openlibrary.org/books/', e.openlibrary_edition_id) AS openlibrary_edition_url, CONCAT('https://openlibrary.org/works/', e.work_key) AS openlibrary_work_url, e.cover_url_large AS cover_url, e.cover_source, COALESCE( json_agg( DISTINCT jsonb_build_object( 'name', a2.name, 'key', a2.author_key, 'openlibrary', CONCAT('https://openlibrary.org', a2.author_key), 'gender', a2.gender, 'nationality', a2.nationality, 'birth_year', a2.birth_year, 'death_year', a2.death_year, 'bio', a2.bio, 'wikidata_id', a2.wikidata_id, 'image', a2.author_photo_url ) ) FILTER (WHERE a2.author_key IS NOT NULL), '[]'::json ) AS authors FROM enriched_authors a JOIN author_works aw ON a.author_key = aw.author_key JOIN enriched_works w ON aw.work_key = w.work_key JOIN enriched_editions e ON e.work_key = w.work_key LEFT JOIN author_works aw2 ON w.work_key = aw2.work_key LEFT JOIN enriched_authors a2 ON aw2.author_key = a2.author_key WHERE a.normalized_name = normalize_author_name(${name}) GROUP BY e.isbn, e.title, e.publication_date, e.publisher, e.page_count, e.format, e.cover_url_large, e.cover_source, e.openlibrary_edition_id, e.work_key, w.title ORDER BY e.publication_date DESC NULLS LAST LIMIT ${limit + 1} OFFSET ${offset} `; const hasMore = dataResult.length > limit; const data = hasMore ? dataResult.slice(0, limit) : dataResult; // Estimate total: if hasMore, we know there's at least one more page const total = hasMore ? offset + limit + 1 : offset + data.length; return { data, total, totalEstimated: true, }; } /** * Search by title using GIN trigram indexes (fuzzy search) * Uses dynamic threshold tuning for better precision/recall balance */ async function searchByTitle( sql: any, title: string, limit: number, offset: number ): Promise<{ data: any[]; total: number; totalEstimated?: boolean }> { // Dynamic threshold based on query length // Shorter queries = higher threshold (more precise) // Longer queries = lower threshold (more recall) const threshold = title.length <= 5 ? 0.6 : title.length <= 10 ? 0.5 : 0.4; // Set work_mem for this query (faster sorting of fuzzy results) await sql`SET LOCAL work_mem = '256MB'`; // Set similarity threshold for trigram matching await sql`SET LOCAL pg_trgm.similarity_threshold = ${threshold}`; // OPTIMIZATION: Use LIMIT limit + 1 strategy instead of separate COUNT(*) query // The separate COUNT query on fuzzy search is extremely expensive const dataResult: any[] = await sql` SELECT e.isbn AS isbn, e.title, e.publication_date AS publish_date, e.publisher AS publishers, e.page_count AS pages, e.format AS binding, w.title AS work_title, CONCAT('https://openlibrary.org/books/', e.openlibrary_edition_id) AS openlibrary_edition_url, CONCAT('https://openlibrary.org/works/', e.work_key) AS openlibrary_work_url, e.cover_url_large AS cover_url, e.cover_source, similarity(w.title, ${title}) AS title_score, COALESCE( json_agg( DISTINCT jsonb_build_object( 'name', a.name, 'key', a.author_key, 'openlibrary', CONCAT('https://openlibrary.org', a.author_key), 'gender', a.gender, 'nationality', a.nationality, 'birth_year', a.birth_year, 'death_year', a.death_year, 'bio', a.bio, 'wikidata_id', a.wikidata_id, 'image', a.author_photo_url ) ) FILTER (WHERE a.author_key IS NOT NULL), '[]'::json ) AS authors FROM enriched_works w JOIN enriched_editions e ON e.work_key = w.work_key LEFT JOIN author_works aw ON w.work_key = aw.work_key LEFT JOIN enriched_authors a ON aw.author_key = a.author_key WHERE w.title % ${title} GROUP BY e.isbn, e.title, e.publication_date, e.publisher, e.page_count, e.format, e.cover_url_large, e.cover_source, e.openlibrary_edition_id, e.work_key, w.title ORDER BY title_score DESC, e.publication_date DESC NULLS LAST LIMIT ${limit + 1} OFFSET ${offset} `; const hasMore = dataResult.length > limit; const data = hasMore ? dataResult.slice(0, limit) : dataResult; // Estimate total: if hasMore, we know there's at least one more page const total = hasMore ? offset + limit + 1 : offset + data.length; return { data, total, totalEstimated: true, }; } /** * Converts database row to API response format */ function formatSearchResult(row: any): BookResult { const isbn = row.isbn; const coverUrl = row.cover_url || null; // Generate coverUrls object if ISBN is available (for /covers/:isbn/:size endpoint) const coverUrls = isbn ? { large: `https://alexandria.ooheynerds.com/covers/${isbn}/large`, medium: `https://alexandria.ooheynerds.com/covers/${isbn}/medium`, small: `https://alexandria.ooheynerds.com/covers/${isbn}/small`, } : null; return { title: row.title || '', authors: Array.isArray(row.authors) ? row.authors : [], isbn: isbn || null, coverUrl, // Legacy: direct URL (typically large) coverUrls, // Modern: size-specific URLs coverSource: row.cover_source || null, publish_date: row.publish_date || null, publishers: row.publishers || null, pages: row.pages || null, work_title: row.work_title || null, openlibrary_edition: row.openlibrary_edition_url || null, openlibrary_work: row.openlibrary_work_url || null, binding: row.binding || null, related_isbns: null, }; } // ================================================================================= // Route Definition // ================================================================================= const combinedSearchRoute = createRoute({ method: 'get', path: '/api/search/combined', tags: ['Search'], summary: 'Combined search with auto-detection', description: 'Unified search endpoint that automatically detects query type (ISBN, author, or title) and routes to appropriate search logic. Supports caching with type-specific TTLs.', request: { query: CombinedSearchQuerySchema, }, responses: { 200: { description: 'Search results with query type detection info', content: { 'application/json': { schema: CombinedSearchSuccessSchema, }, }, }, 400: { description: 'Invalid query parameters', content: { 'application/json': { schema: SearchErrorSchema, }, }, }, 500: { description: 'Server error', content: { 'application/json': { schema: SearchErrorSchema, }, }, }, }, }); const app = new OpenAPIHono(); app.openapi(combinedSearchRoute, async (c) => { const startTime = Date.now(); const { q, limit = 10, offset = 0, nocache = false } = c.req.valid('query'); const sql = c.get('sql'); const cache = c.env.CACHE; const logger = c.get('logger'); try { // Stage 1: Detect query type const detection: DetectionResult = await detectQueryType(q, sql); const { type, normalized, confidence } = detection; logger.info('Query type detected', { query: q, type, normalized, confidence, }); // Stage 2: Check cache (unless nocache=true) const cacheKey = buildCombinedCacheKey(type, normalized, limit, offset); if (!nocache && cache) { try { const cached: any = await cache.get(cacheKey, 'json'); if (cached) { logger.info('Cache hit', { type, query: q, key: cacheKey }); // Update response time but keep original cache_hit metadata return createSuccessResponse(c, { ...cached, metadata: { ...cached.metadata, cache_hit: true, response_time_ms: Date.now() - startTime, }, }); } } catch (err) { logger.warn('Cache read failed', { error: err, key: cacheKey }); } } // Stage 3: Execute search based on detected type let results; switch (type) { case 'isbn': results = await searchByISBN(sql, normalized, limit, offset); break; case 'author': results = await searchByAuthor(sql, normalized, limit, offset); break; case 'title': results = await searchByTitle(sql, normalized, limit, offset); break; } // Stage 4: Format response const formattedResults = results.data.map(formatSearchResult); const responseData = { query: { original: q, detected_type: type, normalized, confidence, }, results: formattedResults, pagination: { limit, offset, total: results.total, hasMore: offset + limit < results.total, returnedCount: formattedResults.length, totalEstimated: results.totalEstimated, }, metadata: { cache_hit: false, response_time_ms: Date.now() - startTime, source: 'database', }, }; // Stage 5: Store in cache if (cache && !nocache) { try { const ttl = getCacheTTL(type); await cache.put(cacheKey, JSON.stringify(responseData), { expirationTtl: ttl, }); logger.info('Cache set', { type, query: q, key: cacheKey, ttl }); } catch (err) { logger.warn('Cache write failed', { error: err, key: cacheKey }); } } return createSuccessResponse(c, responseData); } catch (error) { logger.error('Combined search failed', { error, query: q }); if (error instanceof Error) { return createErrorResponse( c, ErrorCode.INTERNAL_ERROR, `Search failed: ${error.message}` ); } return createErrorResponse( c, ErrorCode.INTERNAL_ERROR, 'Search failed' ); } }); export default app;