From 64710167858ea4fadc7e335794730627cf161b73 Mon Sep 17 00:00:00 2001 From: Fili <126945152+filiksyos@users.noreply.github.com> Date: Tue, 6 Oct 2026 16:31:11 +0300 Subject: [PATCH] Fix library search escaping and independent source fallbacks Preserve partial source failures, surface retryable outage states, share hybrid vectors, and cover search regressions. --- .github/workflows/library-search.yml | 35 ++++ README.md | 3 + app/api/library/route.ts | 11 +- app/library/page.tsx | 33 +-- components/library-page.tsx | 108 +++++++--- lib/library-query.ts | 255 ++++++++++------------- lib/library-types.ts | 7 + package.json | 4 +- pnpm-lock.yaml | 290 +++++++++++++++++++++++++++ tests/library-query.test.ts | 194 ++++++++++++++++++ 10 files changed, 742 insertions(+), 198 deletions(-) create mode 100644 .github/workflows/library-search.yml create mode 100644 tests/library-query.test.ts diff --git a/.github/workflows/library-search.yml b/.github/workflows/library-search.yml new file mode 100644 index 0000000..b044812 --- /dev/null +++ b/.github/workflows/library-search.yml @@ -0,0 +1,35 @@ +name: Library search regressions + +on: + pull_request: + paths: + - 'lib/library-*.ts' + - 'lib/embeddings.ts' + - 'lib/azure-openai.ts' + - 'app/api/library/**' + - 'app/library/**' + - 'components/library-page.tsx' + - 'tests/library-*.test.ts' + - 'package.json' + - 'pnpm-lock.yaml' + - '.github/workflows/library-search.yml' + +permissions: + contents: read + +jobs: + library-search: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + version: 10.11.0 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm test:library + - run: pnpm exec tsc --noEmit + - run: pnpm exec eslint lib/library-query.ts lib/library-types.ts app/api/library/route.ts app/library/page.tsx components/library-page.tsx tests/library-*.test.ts diff --git a/README.md b/README.md index ace9032..fd0a95a 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,9 @@ Open [http://localhost:3000](http://localhost:3000). pnpm build pnpm start pnpm lint +pnpm test:library ``` +`test:library` runs credential-free regression tests for library filtering, source fallbacks, and outage handling. + [Sponsor this readme](https://buy.stripe.com/dRmdRaaGu3es6Dz5sgg7e03) diff --git a/app/api/library/route.ts b/app/api/library/route.ts index 9d73e52..a13fcc6 100644 --- a/app/api/library/route.ts +++ b/app/api/library/route.ts @@ -55,12 +55,17 @@ export async function GET(req: NextRequest) { { headers: { // Browse is cacheable; keep CDN warm so Library navigations stay fast. - "Cache-Control": "public, s-maxage=60, stale-while-revalidate=300", + "Cache-Control": result.unavailableSources?.length + ? "private, no-store" + : "public, s-maxage=60, stale-while-revalidate=300", }, } ); } catch (error) { - const message = error instanceof Error ? error.message : "Search failed."; - return NextResponse.json({ error: message }, { status: 500 }); + console.error("[library] request failed:", error); + return NextResponse.json( + { error: "The library is temporarily unavailable. Please try again." }, + { status: 503, headers: { "Cache-Control": "private, no-store" } } + ); } } diff --git a/app/library/page.tsx b/app/library/page.tsx index c95fa89..d02aac8 100644 --- a/app/library/page.tsx +++ b/app/library/page.tsx @@ -1,5 +1,6 @@ import type { Metadata } from "next"; import { unstable_cache } from "next/cache"; +import type { LibraryResult } from "@/lib/library-types"; import { fetchInitialLibrary } from "@/lib/library-query"; import { getSupabase } from "@/lib/supabase"; import { LibraryPage } from "@/components/library-page"; @@ -31,24 +32,23 @@ const INITIAL_LIMIT = 24; const getCachedInitialLibrary = unstable_cache( async () => { const supabase = getSupabase(); - if (!supabase) return { data: [], total: 0 }; - try { - return await fetchInitialLibrary(supabase, INITIAL_LIMIT); - } catch (error) { - console.error( - "[library] initial fetch failed:", - error instanceof Error ? error.message : error - ); - return { data: [], total: 0 }; - } + if (!supabase) throw new Error("Database unavailable."); + return fetchInitialLibrary(supabase, INITIAL_LIMIT); }, - ["library-initial-newest-v3"], + ["library-initial-newest-v4"], { revalidate: 60 } ); export default async function LibraryRoute() { - const { data: initialData, total: initialTotal } = - await getCachedInitialLibrary(); + let initial: LibraryResult = { data: [], total: 0 }; + let initialError: string | undefined; + try { + initial = await getCachedInitialLibrary(); + } catch (error) { + console.error("[library] initial fetch failed:", error); + initialError = "The library is temporarily unavailable. Please try again."; + } + const { data: initialData, total: initialTotal } = initial; const collectionJsonLd = { "@context": "https://schema.org", @@ -68,7 +68,12 @@ export default async function LibraryRoute() { return ( <> - + ); } diff --git a/components/library-page.tsx b/components/library-page.tsx index a4fcf7f..eb662f4 100644 --- a/components/library-page.tsx +++ b/components/library-page.tsx @@ -3,7 +3,7 @@ import { useCallback, useEffect, useRef, useState, useTransition } from "react"; import Link from "next/link"; import { Navbar } from "@/components/navbar"; -import type { LibraryEntry, SortOption } from "@/lib/library-types"; +import type { LibraryEntry, LibraryEntryKind, LibraryResult, SortOption } from "@/lib/library-types"; const SORT_OPTIONS: SortOption[] = ["newest", "trending", "oldest"]; @@ -32,13 +32,21 @@ function relativeTime(iso: string): string { type LibraryPageProps = { initialData: LibraryEntry[]; initialTotal: number; + initialUnavailableSources?: LibraryEntryKind[]; + initialError?: string; }; -export function LibraryPage({ initialData, initialTotal }: LibraryPageProps) { +export function LibraryPage({ + initialData, initialTotal, initialUnavailableSources = [], initialError, +}: LibraryPageProps) { const [search, setSearch] = useState(""); const [sort, setSort] = useState("newest"); const [entries, setEntries] = useState(initialData); const [total, setTotal] = useState(initialTotal); + const [error, setError] = useState(initialError); + const [unavailableSources, setUnavailableSources] = useState(initialUnavailableSources); + const requestRef = useRef(0); + const abortRef = useRef(null); const [page, setPage] = useState(0); const [isPending, startTransition] = useTransition(); const [loadingMore, setLoadingMore] = useState(false); @@ -58,22 +66,42 @@ export function LibraryPage({ initialData, initialTotal }: LibraryPageProps) { page: String(pageVal), limit: String(PAGE_SIZE), }); - const res = await fetch(`/api/library?${params.toString()}`, { - // Browse responses are CDN-cached; search stays private/no-store server-side. - cache: searchVal ? "no-store" : "default", - }); - if (!res.ok) return; - const json = (await res.json()) as { - data: LibraryEntry[]; - total: number; - }; - if (append) { - setEntries((prev) => [...prev, ...json.data]); - } else { - setEntries(json.data); + const requestId = ++requestRef.current; + abortRef.current?.abort(); + const controller = new AbortController(); + abortRef.current = controller; + setError(undefined); + try { + const res = await fetch(`/api/library?${params.toString()}`, { + cache: searchVal ? "no-store" : "default", + signal: controller.signal, + }); + if (!res.ok) throw new Error("Library request failed."); + const json = (await res.json()) as LibraryResult; + if (requestId !== requestRef.current) return; + if (append && json.unavailableSources?.length) { + // A changed source set shifts merged page offsets; retry from page zero. + setUnavailableSources(json.unavailableSources); + setError("Some results couldn’t be loaded. Please try again."); + return; + } + if (append) { + setEntries((prev) => [...prev, ...json.data]); + } else { + setEntries(json.data); + } + setTotal(json.total); + setUnavailableSources(json.unavailableSources ?? []); + setPage(pageVal); + } catch { + if (controller.signal.aborted || requestId !== requestRef.current) return; + setError("The library is temporarily unavailable. Please try again."); + if (!append) { + setEntries([]); + setTotal(0); + setUnavailableSources([]); + } } - setTotal(json.total); - setPage(pageVal); }, [] ); @@ -84,21 +112,27 @@ export function LibraryPage({ initialData, initialTotal }: LibraryPageProps) { isFirstRender.current = false; return; } + ++requestRef.current; + abortRef.current?.abort(); if (searchTimerRef.current) clearTimeout(searchTimerRef.current); searchTimerRef.current = setTimeout(() => { - startTransition(() => { - void fetchPage(search, sort, 0, false); + startTransition(async () => { + await fetchPage(search, sort, 0, false); }); }, 300); return () => { if (searchTimerRef.current) clearTimeout(searchTimerRef.current); + abortRef.current?.abort(); }; }, [search, sort, fetchPage]); async function handleLoadMore() { setLoadingMore(true); - await fetchPage(search, sort, page + 1, true); - setLoadingMore(false); + try { + await fetchPage(search, sort, page + 1, true); + } finally { + setLoadingMore(false); + } } const hasMore = entries.length < total; @@ -114,7 +148,7 @@ export function LibraryPage({ initialData, initialTotal }: LibraryPageProps) {
- {total.toLocaleString()}+ prompts + {error ? "Prompt library" : `${total.toLocaleString()}+ prompts`}
@@ -234,13 +268,27 @@ export function LibraryPage({ initialData, initialTotal }: LibraryPageProps) { + {(error || unavailableSources.length > 0) && ( +
+

{error || `Some results are unavailable (${unavailableSources.join(", ")}). Showing results from the available sources.`}

+ +
+ )} + {/* Count line */} - {search ? ( + {search && !error ? (

{total.toLocaleString()} {" "} - result{total !== 1 ? "s" : ""} + result{total !== 1 ? "s" : ""}{unavailableSources.length > 0 ? " from available sources" : ""}

) : null} @@ -251,8 +299,12 @@ export function LibraryPage({ initialData, initialTotal }: LibraryPageProps) { ) : (
∅ -

No prompts found

-

Try a different search term.

+

+ {error ? "Couldn’t load prompts" : unavailableSources.length > 0 ? "Some results couldn’t be loaded" : "No prompts found"} +

+

+ {error || unavailableSources.length > 0 ? "Please try again in a moment." : "Try a different search term."} +

) ) : ( @@ -264,12 +316,12 @@ export function LibraryPage({ initialData, initialTotal }: LibraryPageProps) { )} {/* Load more */} - {hasMore && ( + {hasMore && !error && unavailableSources.length === 0 && (