diff --git a/convex/skills.ts b/convex/skills.ts index cab96397a..079077231 100644 --- a/convex/skills.ts +++ b/convex/skills.ts @@ -1,5 +1,6 @@ import { getAuthUserId } from '@convex-dev/auth/server' import { getPage, type IndexKey } from 'convex-helpers/server/pagination' +import schema from './schema' import { paginationOptsValidator } from 'convex/server' import { ConvexError, v, type Value } from 'convex/values' import type { Doc, Id } from './_generated/dataModel' @@ -330,22 +331,6 @@ const NONSUSPICIOUS_SORT_INDEXES = { installs: 'by_nonsuspicious_installs', } as const -// Index fields for getPage (avoids importing schema.ts which pulls in auth deps) -const DIGEST_INDEX_FIELDS: Record = { - by_active_created: ['softDeletedAt', 'createdAt'], - by_active_updated: ['softDeletedAt', 'updatedAt'], - by_active_name: ['softDeletedAt', 'displayName'], - by_active_stats_downloads: ['softDeletedAt', 'statsDownloads', 'updatedAt'], - by_active_stats_stars: ['softDeletedAt', 'statsStars', 'updatedAt'], - by_active_stats_installs_all_time: ['softDeletedAt', 'statsInstallsAllTime', 'updatedAt'], - by_nonsuspicious_created: ['softDeletedAt', 'isSuspicious', 'createdAt'], - by_nonsuspicious_updated: ['softDeletedAt', 'isSuspicious', 'updatedAt'], - by_nonsuspicious_name: ['softDeletedAt', 'isSuspicious', 'displayName'], - by_nonsuspicious_downloads: ['softDeletedAt', 'isSuspicious', 'statsDownloads', 'updatedAt'], - by_nonsuspicious_stars: ['softDeletedAt', 'isSuspicious', 'statsStars', 'updatedAt'], - by_nonsuspicious_installs: ['softDeletedAt', 'isSuspicious', 'statsInstallsAllTime', 'updatedAt'], -} - function isSkillVersionId( value: Id<'skillVersions'> | null | undefined, ): value is Id<'skillVersions'> { @@ -2760,10 +2745,11 @@ export const listPublicPageV4 = query({ startInclusive: lastFetchInclusive, endIndexKey: eqPrefix, endInclusive: true, - targetMaxRows: fetchSize, + // endIndexKey causes targetMaxRows to be ignored, so use absoluteMaxRows + absoluteMaxRows: fetchSize, order: dir, index: indexName, - indexFields: DIGEST_INDEX_FIELDS[indexName], + schema, }) // Pair digests with their index keys, then filter diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index af196d205..b14b8a934 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -10,7 +10,9 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as UploadRouteImport } from './routes/upload' +import { Route as TestV4RouteImport } from './routes/test-v4' import { Route as StarsRouteImport } from './routes/stars' +import { Route as Skillsv4RouteImport } from './routes/skillsv4' import { Route as SettingsRouteImport } from './routes/settings' import { Route as SearchRouteImport } from './routes/search' import { Route as ManagementRouteImport } from './routes/management' @@ -30,11 +32,21 @@ const UploadRoute = UploadRouteImport.update({ path: '/upload', getParentRoute: () => rootRouteImport, } as any) +const TestV4Route = TestV4RouteImport.update({ + id: '/test-v4', + path: '/test-v4', + getParentRoute: () => rootRouteImport, +} as any) const StarsRoute = StarsRouteImport.update({ id: '/stars', path: '/stars', getParentRoute: () => rootRouteImport, } as any) +const Skillsv4Route = Skillsv4RouteImport.update({ + id: '/skillsv4', + path: '/skillsv4', + getParentRoute: () => rootRouteImport, +} as any) const SettingsRoute = SettingsRouteImport.update({ id: '/settings', path: '/settings', @@ -109,7 +121,9 @@ export interface FileRoutesByFullPath { '/management': typeof ManagementRoute '/search': typeof SearchRoute '/settings': typeof SettingsRoute + '/skillsv4': typeof Skillsv4Route '/stars': typeof StarsRoute + '/test-v4': typeof TestV4Route '/upload': typeof UploadRoute '/$owner/$slug': typeof OwnerSlugRoute '/cli/auth': typeof CliAuthRoute @@ -126,7 +140,9 @@ export interface FileRoutesByTo { '/management': typeof ManagementRoute '/search': typeof SearchRoute '/settings': typeof SettingsRoute + '/skillsv4': typeof Skillsv4Route '/stars': typeof StarsRoute + '/test-v4': typeof TestV4Route '/upload': typeof UploadRoute '/$owner/$slug': typeof OwnerSlugRoute '/cli/auth': typeof CliAuthRoute @@ -144,7 +160,9 @@ export interface FileRoutesById { '/management': typeof ManagementRoute '/search': typeof SearchRoute '/settings': typeof SettingsRoute + '/skillsv4': typeof Skillsv4Route '/stars': typeof StarsRoute + '/test-v4': typeof TestV4Route '/upload': typeof UploadRoute '/$owner/$slug': typeof OwnerSlugRoute '/cli/auth': typeof CliAuthRoute @@ -163,7 +181,9 @@ export interface FileRouteTypes { | '/management' | '/search' | '/settings' + | '/skillsv4' | '/stars' + | '/test-v4' | '/upload' | '/$owner/$slug' | '/cli/auth' @@ -180,7 +200,9 @@ export interface FileRouteTypes { | '/management' | '/search' | '/settings' + | '/skillsv4' | '/stars' + | '/test-v4' | '/upload' | '/$owner/$slug' | '/cli/auth' @@ -197,7 +219,9 @@ export interface FileRouteTypes { | '/management' | '/search' | '/settings' + | '/skillsv4' | '/stars' + | '/test-v4' | '/upload' | '/$owner/$slug' | '/cli/auth' @@ -215,7 +239,9 @@ export interface RootRouteChildren { ManagementRoute: typeof ManagementRoute SearchRoute: typeof SearchRoute SettingsRoute: typeof SettingsRoute + Skillsv4Route: typeof Skillsv4Route StarsRoute: typeof StarsRoute + TestV4Route: typeof TestV4Route UploadRoute: typeof UploadRoute OwnerSlugRoute: typeof OwnerSlugRoute CliAuthRoute: typeof CliAuthRoute @@ -234,6 +260,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof UploadRouteImport parentRoute: typeof rootRouteImport } + '/test-v4': { + id: '/test-v4' + path: '/test-v4' + fullPath: '/test-v4' + preLoaderRoute: typeof TestV4RouteImport + parentRoute: typeof rootRouteImport + } '/stars': { id: '/stars' path: '/stars' @@ -241,6 +274,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof StarsRouteImport parentRoute: typeof rootRouteImport } + '/skillsv4': { + id: '/skillsv4' + path: '/skillsv4' + fullPath: '/skillsv4' + preLoaderRoute: typeof Skillsv4RouteImport + parentRoute: typeof rootRouteImport + } '/settings': { id: '/settings' path: '/settings' @@ -343,7 +383,9 @@ const rootRouteChildren: RootRouteChildren = { ManagementRoute: ManagementRoute, SearchRoute: SearchRoute, SettingsRoute: SettingsRoute, + Skillsv4Route: Skillsv4Route, StarsRoute: StarsRoute, + TestV4Route: TestV4Route, UploadRoute: UploadRoute, OwnerSlugRoute: OwnerSlugRoute, CliAuthRoute: CliAuthRoute, diff --git a/src/routes/skills/-useSkillsBrowseModel.ts b/src/routes/skills/-useSkillsBrowseModel.ts index cc61c8517..0ac0c60a6 100644 --- a/src/routes/skills/-useSkillsBrowseModel.ts +++ b/src/routes/skills/-useSkillsBrowseModel.ts @@ -30,10 +30,12 @@ export function useSkillsBrowseModel({ search, navigate, searchInputRef, + useV4 = false, }: { search: SkillsSearchState navigate: SkillsNavigate searchInputRef: RefObject + useV4?: boolean }) { const [query, setQuery] = useState(search.q ?? '') const [searchResults, setSearchResults] = useState>([]) @@ -70,17 +72,33 @@ export function useSkillsBrowseModel({ const fetchPage = useCallback( async (cursor: string | null, generation: number) => { try { - const result = await convexHttp.query(api.skills.listPublicPageV3, { - paginationOpts: { cursor, numItems: pageSize }, - sort: listSort, - dir, - highlightedOnly, - nonSuspiciousOnly, - }) - if (generation !== fetchGeneration.current) return - setListResults((prev) => (cursor ? [...prev, ...result.page] : result.page)) - setListCursor(result.isDone ? null : result.continueCursor) - setListStatus(result.isDone ? 'done' : 'idle') + if (useV4) { + const result = await convexHttp.query(api.skills.listPublicPageV4, { + cursor: cursor ?? undefined, + numItems: pageSize, + sort: listSort, + dir, + highlightedOnly, + nonSuspiciousOnly, + }) + if (generation !== fetchGeneration.current) return + setListResults((prev) => (cursor ? [...prev, ...result.page] : result.page)) + const canAdvance = result.hasMore && result.nextCursor != null + setListCursor(canAdvance ? result.nextCursor : null) + setListStatus(canAdvance ? 'idle' : 'done') + } else { + const result = await convexHttp.query(api.skills.listPublicPageV3, { + paginationOpts: { cursor, numItems: pageSize }, + sort: listSort, + dir, + highlightedOnly, + nonSuspiciousOnly, + }) + if (generation !== fetchGeneration.current) return + setListResults((prev) => (cursor ? [...prev, ...result.page] : result.page)) + setListCursor(result.isDone ? null : result.continueCursor) + setListStatus(result.isDone ? 'done' : 'idle') + } } catch (err) { if (generation !== fetchGeneration.current) return console.error('Failed to fetch skills page:', err) @@ -88,7 +106,7 @@ export function useSkillsBrowseModel({ setListStatus(cursor ? 'idle' : 'done') } }, - [listSort, dir, highlightedOnly, nonSuspiciousOnly], + [listSort, dir, highlightedOnly, nonSuspiciousOnly, useV4], ) // Reset and fetch first page when sort/dir/filters change diff --git a/src/routes/skillsv4.tsx b/src/routes/skillsv4.tsx new file mode 100644 index 000000000..f3c27168e --- /dev/null +++ b/src/routes/skillsv4.tsx @@ -0,0 +1,110 @@ +import { createFileRoute, redirect } from '@tanstack/react-router' +import { useQuery } from 'convex/react' +import { useRef } from 'react' +import { api } from '../../convex/_generated/api' +import { parseSort } from './skills/-params' +import { SkillsResults } from './skills/-SkillsResults' +import { SkillsToolbar } from './skills/-SkillsToolbar' +import { useSkillsBrowseModel, type SkillsSearchState } from './skills/-useSkillsBrowseModel' + +export const Route = createFileRoute('/skillsv4')({ + validateSearch: (search): SkillsSearchState => { + return { + q: typeof search.q === 'string' && search.q.trim() ? search.q : undefined, + sort: typeof search.sort === 'string' ? parseSort(search.sort) : undefined, + dir: search.dir === 'asc' || search.dir === 'desc' ? search.dir : undefined, + highlighted: + search.highlighted === '1' || search.highlighted === 'true' || search.highlighted === true + ? true + : undefined, + nonSuspicious: + search.nonSuspicious === '1' || + search.nonSuspicious === 'true' || + search.nonSuspicious === true + ? true + : undefined, + view: search.view === 'cards' || search.view === 'list' ? search.view : undefined, + focus: search.focus === 'search' ? 'search' : undefined, + } + }, + beforeLoad: ({ search }) => { + const hasQuery = Boolean(search.q?.trim()) + if (hasQuery || search.sort) return + throw redirect({ + to: '/skillsv4', + search: { + q: search.q || undefined, + sort: 'downloads', + dir: search.dir || undefined, + highlighted: search.highlighted || undefined, + nonSuspicious: search.nonSuspicious || undefined, + view: search.view || undefined, + focus: search.focus || undefined, + }, + replace: true, + }) + }, + component: SkillsV4Index, +}) + +function SkillsV4Index() { + const navigate = Route.useNavigate() + const search = Route.useSearch() + const searchInputRef = useRef(null) + const totalSkills = useQuery(api.skills.countPublicSkills) + const totalSkillsText = + typeof totalSkills === 'number' ? totalSkills.toLocaleString('en-US') : null + + const model = useSkillsBrowseModel({ + navigate, + search, + searchInputRef, + useV4: true, + }) + + return ( +
+
+

+ Skills (V4 test) + {totalSkillsText && {` (${totalSkillsText})`}} +

+

+ {model.isLoadingSkills + ? 'Loading skills…' + : `Browse the skill library${model.activeFilters.length ? ` (${model.activeFilters.join(', ')})` : ''}.`} +

+
+
+ + +
+
+ ) +} diff --git a/src/routes/test-v4.tsx b/src/routes/test-v4.tsx new file mode 100644 index 000000000..59afacda9 --- /dev/null +++ b/src/routes/test-v4.tsx @@ -0,0 +1,179 @@ +import { ConvexHttpClient } from 'convex/browser' +import { createFileRoute } from '@tanstack/react-router' +import { useCallback, useState } from 'react' +import { api } from '../../convex/_generated/api' +import { ClientOnly } from '../components/ClientOnly' + +export const Route = createFileRoute('/test-v4')({ + component: () => ( + Loading...}> + + + ), +}) + +const DEFAULT_URL = import.meta.env.VITE_CONVEX_URL ?? '' + +type SortKey = 'newest' | 'updated' | 'downloads' | 'installs' | 'stars' | 'name' + +function TestV4() { + const [url, setUrl] = useState(DEFAULT_URL) + const [sort, setSort] = useState('downloads') + const [dir, setDir] = useState<'asc' | 'desc'>('desc') + const [numItems, setNumItems] = useState(5) + const [nonSuspiciousOnly, setNonSuspiciousOnly] = useState(true) + const [highlightedOnly, setHighlightedOnly] = useState(false) + const [cursor, setCursor] = useState(null) + + const [results, setResults] = useState([]) + const [response, setResponse] = useState(null) + const [error, setError] = useState(null) + const [loading, setLoading] = useState(false) + const [history, setHistory] = useState>([]) + + const fetchPage = useCallback(async (cursorOverride?: string | null) => { + console.log('[test-v4] fetchPage called, cursorOverride:', cursorOverride) + setLoading(true) + setError(null) + const c = cursorOverride !== undefined ? cursorOverride : cursor + const label = `sort=${sort} dir=${dir} n=${numItems} cursor=${c ? c.slice(0, 30) + '...' : 'null'}` + const queryArgs = { + cursor: c ?? undefined, + numItems, + sort, + dir, + nonSuspiciousOnly, + highlightedOnly, + } + console.log('[test-v4] calling V4 with args:', JSON.stringify(queryArgs)) + try { + const client = new ConvexHttpClient(url) + const result = await client.query(api.skills.listPublicPageV4, queryArgs) + console.log('[test-v4] result:', JSON.stringify(result).slice(0, 500)) + setResponse(result) + setResults((prev) => c ? [...prev, ...result.page] : result.page) + setCursor(result.nextCursor) + setHistory((prev) => [...prev, { label, response: result }]) + } catch (err) { + console.error('[test-v4] error:', err) + const msg = err instanceof Error ? err.message : String(err) + setError(msg) + setHistory((prev) => [...prev, { label, error: msg, response: null }]) + } finally { + setLoading(false) + } + }, [url, sort, dir, numItems, nonSuspiciousOnly, highlightedOnly, cursor]) + + const reset = () => { + setCursor(null) + setResults([]) + setResponse(null) + setError(null) + } + + return ( +
+

V4 Pagination Test

+ +
+ +
+ + + + + +
+
+ +
+ + + +
+ + {loading &&
Loading...
} + {error &&
Error: {error}
} + + {response !== null && ( +
+ Raw response ({String((response as { page: unknown[] }).page?.length)} items, hasMore: {String((response as { hasMore: boolean }).hasMore)}) +
+            {JSON.stringify(response, null, 2)}
+          
+
+ )} + +

Accumulated results ({results.length})

+ + + + + + + + + + + + + + {results.map((item: unknown, i: number) => { + const r = item as { skill: { slug: string; displayName: string; stats: { downloads: number; stars: number; installsAllTime?: number } }; ownerHandle?: string | null } + return ( + + + + + + + + + + ) + })} + +
#slugdisplayNamedownloadsstarsinstallsowner
{i + 1}{r.skill.slug}{r.skill.displayName}{r.skill.stats.downloads}{r.skill.stats.stars}{r.skill.stats.installsAllTime ?? 0}{r.ownerHandle ?? '—'}
+ + {history.length > 0 && ( + <> +

Request history

+ {history.map((h, i) => ( +
+ [{i}] {h.label} {h.error ? `ERROR: ${h.error}` : `→ ${String((h.response as { page: unknown[] })?.page?.length)} items`} +
+ ))} + + )} +
+ ) +}