Skip to content

Commit 8039e14

Browse files
authored
fix: support sorting algolia search results (#1227)
1 parent 0c035c8 commit 8039e14

File tree

9 files changed

+247
-180
lines changed

9 files changed

+247
-180
lines changed

app/components/Package/ListToolbar.vue

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ const props = defineProps<{
3030
activeFilters: FilterChip[]
3131
/** When true, shows search-specific UI (relevance sort, no filters) */
3232
searchContext?: boolean
33+
/** Sort keys to force-disable (e.g. when the current provider doesn't support them) */
34+
disabledSortKeys?: SortKey[]
3335
}>()
3436
3537
const { t } = useI18n()
@@ -58,17 +60,20 @@ const showingFiltered = computed(() => props.filteredCount !== props.totalCount)
5860
const currentSort = computed(() => parseSortOption(sortOption.value))
5961
6062
// Get available sort keys based on context
63+
const disabledSet = computed(() => new Set(props.disabledSortKeys ?? []))
64+
6165
const availableSortKeys = computed(() => {
66+
const applyDisabled = (k: (typeof SORT_KEYS)[number]) => ({
67+
...k,
68+
disabled: k.disabled || disabledSet.value.has(k.key),
69+
})
70+
6271
if (props.searchContext) {
63-
// In search context: show relevance (enabled) and others (disabled)
64-
return SORT_KEYS.filter(k => !k.searchOnly || k.key === 'relevance').map(k =>
65-
Object.assign({}, k, {
66-
disabled: k.key !== 'relevance',
67-
}),
68-
)
72+
// In search context: show relevance + non-disabled sorts (downloads, updated, name)
73+
return SORT_KEYS.filter(k => !k.searchOnly || k.key === 'relevance').map(applyDisabled)
6974
}
7075
// In org/user context: hide search-only sorts
71-
return SORT_KEYS.filter(k => !k.searchOnly)
76+
return SORT_KEYS.filter(k => !k.searchOnly).map(applyDisabled)
7277
})
7378
7479
// Handle sort key change from dropdown
@@ -182,9 +187,9 @@ function getSortKeyLabelKey(key: SortKey): string {
182187
</div>
183188
</div>
184189

185-
<!-- Sort direction toggle (hidden in search context) -->
190+
<!-- Sort direction toggle -->
186191
<button
187-
v-if="!searchContext"
192+
v-if="!searchContext || currentSort.key !== 'relevance'"
188193
type="button"
189194
class="p-1.5 rounded border border-border bg-bg-subtle text-fg-muted hover:text-fg hover:border-border-hover transition-colors duration-200 focus-visible:ring-2 focus-visible:ring-fg focus-visible:ring-offset-2 focus-visible:ring-offset-bg"
190195
:aria-label="$t('filters.sort.toggle_direction')"

app/composables/npm/useAlgoliaSearch.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,10 +229,50 @@ export function useAlgoliaSearch() {
229229
}
230230
}
231231

232+
/**
233+
* Fetch metadata for specific packages by exact name.
234+
* Uses Algolia's getObjects REST API to look up packages by objectID
235+
* (which equals the package name in the npm-search index).
236+
*/
237+
async function getPackagesByName(packageNames: string[]): Promise<NpmSearchResponse> {
238+
if (packageNames.length === 0) {
239+
return { isStale: false, objects: [], total: 0, time: new Date().toISOString() }
240+
}
241+
242+
// Algolia getObjects REST API: fetch up to 1000 objects by ID in a single request
243+
const response = await $fetch<{ results: (AlgoliaHit | null)[] }>(
244+
`https://${algolia.appId}-dsn.algolia.net/1/indexes/*/objects`,
245+
{
246+
method: 'POST',
247+
headers: {
248+
'x-algolia-api-key': algolia.apiKey,
249+
'x-algolia-application-id': algolia.appId,
250+
},
251+
body: {
252+
requests: packageNames.map(name => ({
253+
indexName,
254+
objectID: name,
255+
attributesToRetrieve: ATTRIBUTES_TO_RETRIEVE,
256+
})),
257+
},
258+
},
259+
)
260+
261+
const hits = response.results.filter((r): r is AlgoliaHit => r !== null && 'name' in r)
262+
return {
263+
isStale: false,
264+
objects: hits.map(hitToSearchResult),
265+
total: hits.length,
266+
time: new Date().toISOString(),
267+
}
268+
}
269+
232270
return {
233271
/** Search packages by text query */
234272
search,
235273
/** Fetch all packages for an owner (org or user) */
236274
searchByOwner,
275+
/** Fetch metadata for specific packages by exact name */
276+
getPackagesByName,
237277
}
238278
}

app/composables/npm/useNpmSearch.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -307,9 +307,13 @@ export function useNpmSearch(
307307
)
308308

309309
// Re-search when provider changes
310-
watch(searchProvider, () => {
310+
watch(searchProvider, async () => {
311311
cache.value = null
312-
asyncData.refresh()
312+
await asyncData.refresh()
313+
const targetSize = toValue(options).size
314+
if (targetSize) {
315+
await fetchMore(targetSize)
316+
}
313317
})
314318

315319
// Computed data that uses cache

app/composables/npm/useOrgPackages.ts

Lines changed: 29 additions & 124 deletions
Original file line numberDiff line numberDiff line change
@@ -1,103 +1,27 @@
1-
import type { NuxtApp } from '#app'
21
import type { NpmSearchResponse, NpmSearchResult, MinimalPackument } from '#shared/types'
32
import { emptySearchResponse, packumentToSearchResult } from './useNpmSearch'
43
import { mapWithConcurrency } from '#shared/utils/async'
54

6-
/**
7-
* Fetch downloads for multiple packages.
8-
* Returns a map of package name -> weekly downloads.
9-
* Uses bulk API for unscoped packages, parallel individual requests for scoped.
10-
* Note: npm bulk downloads API does not support scoped packages.
11-
*/
12-
async function fetchBulkDownloads(
13-
$npmApi: NuxtApp['$npmApi'],
14-
packageNames: string[],
15-
options: Parameters<typeof $fetch>[1] = {},
16-
): Promise<Map<string, number>> {
17-
const downloads = new Map<string, number>()
18-
if (packageNames.length === 0) return downloads
19-
20-
// Separate scoped and unscoped packages
21-
const scopedPackages = packageNames.filter(n => n.startsWith('@'))
22-
const unscopedPackages = packageNames.filter(n => !n.startsWith('@'))
23-
24-
// Fetch unscoped packages via bulk API (max 128 per request)
25-
const bulkPromises: Promise<void>[] = []
26-
const chunkSize = 100
27-
for (let i = 0; i < unscopedPackages.length; i += chunkSize) {
28-
const chunk = unscopedPackages.slice(i, i + chunkSize)
29-
bulkPromises.push(
30-
(async () => {
31-
try {
32-
const response = await $npmApi<Record<string, { downloads: number } | null>>(
33-
`/downloads/point/last-week/${chunk.join(',')}`,
34-
options,
35-
)
36-
for (const [name, data] of Object.entries(response.data)) {
37-
if (data?.downloads !== undefined) {
38-
downloads.set(name, data.downloads)
39-
}
40-
}
41-
} catch {
42-
// Ignore errors - downloads are optional
43-
}
44-
})(),
45-
)
46-
}
47-
48-
// Fetch scoped packages in parallel batches (concurrency limit to avoid overwhelming the API)
49-
// Use Promise.allSettled to not fail on individual errors
50-
const scopedBatchSize = 20 // Concurrent requests per batch
51-
for (let i = 0; i < scopedPackages.length; i += scopedBatchSize) {
52-
const batch = scopedPackages.slice(i, i + scopedBatchSize)
53-
bulkPromises.push(
54-
(async () => {
55-
const results = await Promise.allSettled(
56-
batch.map(async name => {
57-
const encoded = encodePackageName(name)
58-
const { data } = await $npmApi<{ downloads: number }>(
59-
`/downloads/point/last-week/${encoded}`,
60-
)
61-
return { name, downloads: data.downloads }
62-
}),
63-
)
64-
for (const result of results) {
65-
if (result.status === 'fulfilled' && result.value.downloads !== undefined) {
66-
downloads.set(result.value.name, result.value.downloads)
67-
}
68-
}
69-
})(),
70-
)
71-
}
72-
73-
// Wait for all fetches to complete
74-
await Promise.all(bulkPromises)
75-
76-
return downloads
77-
}
78-
795
/**
806
* Fetch all packages for an npm organization.
817
*
82-
* Always uses the npm registry's org endpoint as the source of truth for which
83-
* packages belong to the org. When Algolia is enabled, uses it to quickly fetch
84-
* metadata for those packages (instead of N+1 packument fetches).
8+
* 1. Gets the authoritative package list from the npm registry (single request)
9+
* 2. Fetches metadata from Algolia by exact name (single request)
10+
* 3. Falls back to individual packument fetches when Algolia is unavailable
8511
*/
8612
export function useOrgPackages(orgName: MaybeRefOrGetter<string>) {
8713
const { searchProvider } = useSearchProvider()
88-
const { searchByOwner } = useAlgoliaSearch()
14+
const { getPackagesByName } = useAlgoliaSearch()
8915

9016
const asyncData = useLazyAsyncData(
9117
() => `org-packages:${searchProvider.value}:${toValue(orgName)}`,
92-
async ({ $npmRegistry, $npmApi, ssrContext }, { signal }) => {
18+
async ({ $npmRegistry, ssrContext }, { signal }) => {
9319
const org = toValue(orgName)
9420
if (!org) {
9521
return emptySearchResponse
9622
}
9723

98-
// Always get the authoritative package list from the npm registry.
99-
// Algolia's owner.name filter doesn't precisely match npm org membership
100-
// (e.g. it includes @nuxtjs/* packages for the @nuxt org).
24+
// Get the authoritative package list from the npm registry (single request)
10125
let packageNames: string[]
10226
try {
10327
const { packages } = await $fetch<{ packages: string[]; count: number }>(
@@ -126,60 +50,41 @@ export function useOrgPackages(orgName: MaybeRefOrGetter<string>) {
12650
return emptySearchResponse
12751
}
12852

129-
// --- Algolia fast path: use Algolia to get metadata for known packages ---
53+
// Fetch metadata + downloads from Algolia (single request via getObjects)
13054
if (searchProvider.value === 'algolia') {
13155
try {
132-
const response = await searchByOwner(org)
56+
const response = await getPackagesByName(packageNames)
13357
if (response.objects.length > 0) {
134-
// Filter Algolia results to only include packages that are
135-
// actually in the org (per the npm registry's authoritative list)
136-
const orgPackageSet = new Set(packageNames.map(n => n.toLowerCase()))
137-
const filtered = response.objects.filter(obj =>
138-
orgPackageSet.has(obj.package.name.toLowerCase()),
139-
)
140-
141-
if (filtered.length > 0) {
142-
return {
143-
...response,
144-
objects: filtered,
145-
total: filtered.length,
146-
}
147-
}
58+
return response
14859
}
14960
} catch {
15061
// Fall through to npm registry path
15162
}
15263
}
15364

154-
// --- npm registry path: fetch packuments individually ---
155-
const [packuments, downloads] = await Promise.all([
156-
(async () => {
157-
const results = await mapWithConcurrency(
158-
packageNames,
159-
async name => {
160-
try {
161-
const encoded = encodePackageName(name)
162-
const { data: pkg } = await $npmRegistry<MinimalPackument>(`/${encoded}`, {
163-
signal,
164-
})
165-
return pkg
166-
} catch {
167-
return null
168-
}
169-
},
170-
10,
171-
)
172-
return results.filter(
173-
(pkg): pkg is MinimalPackument => pkg !== null && !!pkg['dist-tags'],
174-
)
175-
})(),
176-
fetchBulkDownloads($npmApi, packageNames, { signal }),
177-
])
65+
// npm fallback: fetch packuments individually
66+
const packuments = await mapWithConcurrency(
67+
packageNames,
68+
async name => {
69+
try {
70+
const encoded = encodePackageName(name)
71+
const { data: pkg } = await $npmRegistry<MinimalPackument>(`/${encoded}`, {
72+
signal,
73+
})
74+
return pkg
75+
} catch {
76+
return null
77+
}
78+
},
79+
10,
80+
)
17881

179-
const results: NpmSearchResult[] = packuments.map(pkg =>
180-
packumentToSearchResult(pkg, downloads.get(pkg.name)),
82+
const validPackuments = packuments.filter(
83+
(pkg): pkg is MinimalPackument => pkg !== null && !!pkg['dist-tags'],
18184
)
18285

86+
const results: NpmSearchResult[] = validPackuments.map(pkg => packumentToSearchResult(pkg))
87+
18388
return {
18489
isStale: false,
18590
objects: results,

0 commit comments

Comments
 (0)