Skip to content

Commit 2f85269

Browse files
Adebesin-Cellclaude
andcommitted
fix: batch Algolia getObjects requests for large orgs
The root cause of #1946 and #2507 is that Algolia's getObjects API has a 1000-item limit. Sending >1000 names returns a 400, which triggers the slow npm fallback path (1 request per package). Fix: batch getPackagesByName into chunks of 1000 with a concurrency limit of 3. The org page and useOrgPackages stay unchanged — all packages are fetched upfront and sorted client-side. Client-side paging is tracked separately in #2529. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 91aaf01 commit 2f85269

File tree

6 files changed

+52
-288
lines changed

6 files changed

+52
-288
lines changed

app/composables/npm/useAlgoliaSearch.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -254,8 +254,14 @@ export function useAlgoliaSearch() {
254254
batches.push(packageNames.slice(i, i + BATCH_SIZE))
255255
}
256256

257-
const results = await Promise.all(batches.map(batch => getPackagesByNameSlice(batch)))
258-
const allObjects = results.flat()
257+
// Fetch batches with concurrency limit to avoid overwhelming the API
258+
const CONCURRENCY = 3
259+
const allObjects: NpmSearchResult[] = []
260+
for (let i = 0; i < batches.length; i += CONCURRENCY) {
261+
const chunk = batches.slice(i, i + CONCURRENCY)
262+
const results = await Promise.all(chunk.map(batch => getPackagesByNameSlice(batch)))
263+
allObjects.push(...results.flat())
264+
}
259265

260266
return {
261267
isStale: false,
@@ -367,6 +373,5 @@ export function useAlgoliaSearch() {
367373
searchWithSuggestions,
368374
searchByOwner,
369375
getPackagesByName,
370-
getPackagesByNameSlice,
371376
}
372377
}

app/composables/npm/useOrgPackages.ts

Lines changed: 26 additions & 161 deletions
Original file line numberDiff line numberDiff line change
@@ -2,33 +2,12 @@ import type { NpmSearchResponse, NpmSearchResult, PackageMetaResponse } from '#s
22
import { emptySearchResponse, metaToSearchResult } from './search-utils'
33
import { mapWithConcurrency } from '#shared/utils/async'
44

5-
/** Number of packages to fetch metadata for in the initial load */
6-
const INITIAL_BATCH_SIZE = 50
7-
8-
/** Max names per Algolia getObjects request */
9-
const ALGOLIA_BATCH_SIZE = 1000
10-
11-
export interface OrgPackagesResponse extends NpmSearchResponse {
12-
/** Total number of packages in the org (may exceed objects.length before loadAll) */
13-
totalPackages: number
14-
/** All package names in the org (used by loadMore to know what to fetch next) */
15-
allPackageNames: string[]
16-
}
17-
18-
function emptyOrgResponse(): OrgPackagesResponse {
19-
return {
20-
...emptySearchResponse(),
21-
totalPackages: 0,
22-
allPackageNames: [],
23-
}
24-
}
25-
265
/**
27-
* Fetch packages for an npm organization with progressive loading.
6+
* Fetch all packages for an npm organization.
287
*
298
* 1. Gets the authoritative package list from the npm registry (single request)
30-
* 2. Fetches metadata for the first batch immediately (fast SSR)
31-
* 3. Remaining packages are loaded on-demand via `loadAll()`
9+
* 2. Fetches metadata from Algolia by exact name (batched, max 1000 per request)
10+
* 3. Falls back to lightweight server-side package-meta lookups
3211
*/
3312
export function useOrgPackages(orgName: MaybeRefOrGetter<string>) {
3413
const route = useRoute()
@@ -38,22 +17,17 @@ export function useOrgPackages(orgName: MaybeRefOrGetter<string>) {
3817
if (p === 'npm' || searchProvider.value === 'npm') return 'npm'
3918
return 'algolia'
4019
})
41-
const { getPackagesByNameSlice } = useAlgoliaSearch()
42-
43-
const loadedObjects = shallowRef<NpmSearchResult[]>([])
44-
45-
// Promise lock — scoped inside the composable to avoid cross-instance sharing
46-
let loadAllPromise: Promise<void> | null = null
20+
const { getPackagesByName } = useAlgoliaSearch()
4721

4822
const asyncData = useLazyAsyncData(
4923
() => `org-packages:${searchProviderValue.value}:${toValue(orgName)}`,
5024
async ({ ssrContext }, { signal }) => {
5125
const org = toValue(orgName)
5226
if (!org) {
53-
return emptyOrgResponse()
27+
return emptySearchResponse()
5428
}
5529

56-
// Get the authoritative package list from the npm registry
30+
// Get the authoritative package list from the npm registry (single request)
5731
let packageNames: string[]
5832
try {
5933
const { packages } = await $fetch<{ packages: string[]; count: number }>(
@@ -77,127 +51,32 @@ export function useOrgPackages(orgName: MaybeRefOrGetter<string>) {
7751
}
7852

7953
if (packageNames.length === 0) {
80-
loadedObjects.value = []
81-
return emptyOrgResponse()
54+
return emptySearchResponse()
8255
}
8356

84-
const initialNames = packageNames.slice(0, INITIAL_BATCH_SIZE)
85-
86-
// Fetch metadata for first batch
87-
let initialObjects: NpmSearchResult[] = []
88-
57+
// Fetch metadata from Algolia (batched in chunks of 1000, parallel)
8958
if (searchProviderValue.value === 'algolia') {
9059
try {
91-
initialObjects = await getPackagesByNameSlice(initialNames)
60+
const response = await getPackagesByName(packageNames)
61+
if (response.objects.length > 0) {
62+
return response
63+
}
9264
} catch {
93-
// Fall through to npm fallback
65+
// Fall through to npm registry path
9466
}
9567
}
9668

9769
// Staleness guard
98-
if (toValue(orgName) !== org) return emptyOrgResponse()
99-
100-
// npm fallback for initial batch
101-
if (initialObjects.length === 0) {
102-
const metaResults = await mapWithConcurrency(
103-
initialNames,
104-
async name => {
105-
try {
106-
return await $fetch<PackageMetaResponse>(
107-
`/api/registry/package-meta/${encodePackageName(name)}`,
108-
{ signal },
109-
)
110-
} catch {
111-
return null
112-
}
113-
},
114-
10,
115-
)
116-
117-
if (toValue(orgName) !== org) return emptyOrgResponse()
118-
119-
initialObjects = metaResults
120-
.filter((meta): meta is PackageMetaResponse => meta !== null)
121-
.map(metaToSearchResult)
122-
}
123-
124-
loadedObjects.value = initialObjects
125-
126-
return {
127-
isStale: false,
128-
objects: initialObjects,
129-
total: initialObjects.length,
130-
totalPackages: packageNames.length,
131-
allPackageNames: packageNames,
132-
time: new Date().toISOString(),
133-
} satisfies OrgPackagesResponse
134-
},
135-
{ default: emptyOrgResponse },
136-
)
137-
138-
/** Read allPackageNames from async data (survives SSR→client hydration via Nuxt payload). */
139-
function allPackageNames(): string[] {
140-
return asyncData.data.value?.allPackageNames ?? []
141-
}
142-
143-
/** Load the next batch of packages (default: 1 Algolia batch of 1000). */
144-
async function loadMore(count: number = ALGOLIA_BATCH_SIZE): Promise<void> {
145-
const loadedSet = new Set(loadedObjects.value.map(o => o.package.name))
146-
if (loadedSet.size >= allPackageNames().length) return
147-
148-
// Reuse in-flight promise to prevent duplicate fetches
149-
if (loadAllPromise) {
150-
await loadAllPromise
151-
return
152-
}
153-
154-
loadAllPromise = _doLoadMore(count)
155-
try {
156-
await loadAllPromise
157-
} finally {
158-
loadAllPromise = null
159-
}
160-
}
161-
162-
/** Load ALL remaining packages (used when filters need the full dataset). */
163-
async function loadAll(): Promise<void> {
164-
const remaining = allPackageNames().length - loadedObjects.value.length
165-
if (remaining <= 0) return
166-
await loadMore(remaining)
167-
}
168-
169-
async function _doLoadMore(count: number): Promise<void> {
170-
const names = allPackageNames()
171-
const current = loadedObjects.value
172-
const loadedSet = new Set(current.map(o => o.package.name))
173-
const remainingNames = names.filter(n => !loadedSet.has(n)).slice(0, count)
174-
if (remainingNames.length === 0) return
175-
176-
const org = toValue(orgName)
177-
let newObjects: NpmSearchResult[] = []
178-
179-
if (searchProviderValue.value === 'algolia') {
180-
const batches: string[][] = []
181-
for (let i = 0; i < remainingNames.length; i += ALGOLIA_BATCH_SIZE) {
182-
batches.push(remainingNames.slice(i, i + ALGOLIA_BATCH_SIZE))
183-
}
70+
if (toValue(orgName) !== org) return emptySearchResponse()
18471

185-
const results = await Promise.allSettled(batches.map(batch => getPackagesByNameSlice(batch)))
186-
187-
if (toValue(orgName) !== org) return
188-
189-
for (const result of results) {
190-
if (result.status === 'fulfilled') {
191-
newObjects.push(...result.value)
192-
}
193-
}
194-
} else {
72+
// npm fallback: fetch lightweight metadata via server proxy
19573
const metaResults = await mapWithConcurrency(
196-
remainingNames,
74+
packageNames,
19775
async name => {
19876
try {
19977
return await $fetch<PackageMetaResponse>(
20078
`/api/registry/package-meta/${encodePackageName(name)}`,
79+
{ signal },
20180
)
20281
} catch {
20382
return null
@@ -206,33 +85,19 @@ export function useOrgPackages(orgName: MaybeRefOrGetter<string>) {
20685
10,
20786
)
20887

209-
if (toValue(orgName) !== org) return
210-
211-
newObjects = metaResults
88+
const results: NpmSearchResult[] = metaResults
21289
.filter((meta): meta is PackageMetaResponse => meta !== null)
21390
.map(metaToSearchResult)
214-
}
21591

216-
if (newObjects.length > 0) {
217-
const deduped = newObjects.filter(o => !loadedSet.has(o.package.name))
218-
const all = [...current, ...deduped]
219-
loadedObjects.value = all
220-
221-
// Update asyncData so the page sees the new objects
222-
asyncData.data.value = {
92+
return {
22393
isStale: false,
224-
objects: all,
225-
total: all.length,
226-
totalPackages: names.length,
227-
allPackageNames: names,
94+
objects: results,
95+
total: results.length,
22896
time: new Date().toISOString(),
229-
}
230-
}
231-
}
97+
} satisfies NpmSearchResponse
98+
},
99+
{ default: emptySearchResponse },
100+
)
232101

233-
return {
234-
...asyncData,
235-
loadMore,
236-
loadAll,
237-
}
102+
return asyncData
238103
}

app/composables/useVisibleItems.ts

Lines changed: 6 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,8 @@
11
import { computed, shallowRef, toValue } from 'vue'
22
import type { MaybeRefOrGetter } from 'vue'
33

4-
export interface UseVisibleItemsOptions {
5-
/**
6-
* Called when expanding. Useful for loading remaining data on demand.
7-
* If it returns a promise, `isExpanding` will be `true` until it resolves.
8-
* Return `false` to signal a partial load — `showAll` stays false so
9-
* `hasMore` remains true and the user can retry.
10-
*/
11-
onExpand?: () => void | boolean | Promise<void | boolean>
12-
}
13-
14-
export function useVisibleItems<T>(
15-
items: MaybeRefOrGetter<T[]>,
16-
limit: number,
17-
options?: UseVisibleItemsOptions,
18-
) {
4+
export function useVisibleItems<T>(items: MaybeRefOrGetter<T[]>, limit: number) {
195
const showAll = shallowRef(false)
20-
const isExpanding = shallowRef(false)
216

227
const visibleItems = computed(() => {
238
const list = toValue(items)
@@ -30,30 +15,15 @@ export function useVisibleItems<T>(
3015

3116
const hasMore = computed(() => !showAll.value && toValue(items).length > limit)
3217

33-
const expand = async () => {
34-
if (showAll.value) return
35-
let fullyLoaded = true
36-
if (options?.onExpand) {
37-
isExpanding.value = true
38-
try {
39-
const result = await options.onExpand()
40-
if (result === false) fullyLoaded = false
41-
} finally {
42-
isExpanding.value = false
43-
}
44-
}
45-
if (fullyLoaded) showAll.value = true
18+
const expand = () => {
19+
showAll.value = true
4620
}
4721
const collapse = () => {
4822
showAll.value = false
4923
}
50-
const toggle = async () => {
51-
if (showAll.value) {
52-
collapse()
53-
} else {
54-
await expand()
55-
}
24+
const toggle = () => {
25+
showAll.value = !showAll.value
5626
}
5727

58-
return { visibleItems, hiddenCount, hasMore, isExpanding, showAll, expand, collapse, toggle }
28+
return { visibleItems, hiddenCount, hasMore, showAll, expand, collapse, toggle }
5929
}

0 commit comments

Comments
 (0)