Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 108 additions & 0 deletions src/lib/rest-client/rest-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
SearchParams,
__resetHiddenCountCacheForTests,
__resetRankingDataCacheForTests,
__resetFetchedCatalogCacheForTests,
} from './rest-client';

// Mock fetch globally
Expand Down Expand Up @@ -98,6 +99,11 @@ describe('rest-client', () => {
// stay accurate and independent of test order.
__resetRankingDataCacheForTests();

// The full-fetch path caches the merged catalog per filter set (see
// rest-client.ts); reset it so each test's request-count assertions stay
// accurate and independent of test order.
__resetFetchedCatalogCacheForTests();

// Reset storage - clear store and restore implementations
Object.keys(storageStore).forEach((key) => delete storageStore[key]);
storageMock.getItem.mockImplementation((key: string) => storageStore[key] ?? null);
Expand Down Expand Up @@ -622,6 +628,108 @@ describe('rest-client', () => {
});
});

describe('searchPackages fetched-catalog cache', () => {
const catalog = ['conn-a', 'conn-b', 'conn-c', 'conn-d', 'conn-e'].map((name) => ({
name,
version: '1.0.0',
}));

// Serves the catalog by offset/limit like Central does; ranking-data and any
// other non-search URL gets an empty payload.
const serveCatalog = () => {
mockFetch.mockImplementation(async (url: string) => {
if (!url.includes('offset=')) {
return { ok: true, json: () => Promise.resolve({ packages: {} }) };
}
const params = new URL(url, 'https://example.com').searchParams;
const offset = Number(params.get('offset'));
const limit = Number(params.get('limit'));
return {
ok: true,
json: () =>
Promise.resolve(
createMockApiResponse(
catalog.slice(offset, offset + limit),
catalog.length,
offset,
limit
)
),
};
});
};
const centralCalls = () =>
mockFetch.mock.calls.filter(([url]) => String(url).includes('offset=')).length;
const base: SearchParams = { query: 'conn', offset: 0, limit: 2, sort: 'name-asc' };

it('should not re-fetch from Central when only the page changes', async () => {
serveCatalog();
const page1 = await searchPackages(base);
// One full fetch = 1 count probe + 1 batch request.
expect(centralCalls()).toBe(2);

const page2 = await searchPackages({ ...base, offset: 2 });
expect(centralCalls()).toBe(2);
expect(page1.packages.map((p) => p.name)).toEqual(['conn-a', 'conn-b']);
expect(page2.packages.map((p) => p.name)).toEqual(['conn-c', 'conn-d']);
expect(page2.count).toBe(5);
});

it('should not re-fetch from Central when only the sort changes, and sort in memory', async () => {
serveCatalog();
await searchPackages(base);
expect(centralCalls()).toBe(2);

const desc = await searchPackages({ ...base, sort: 'name-desc' });
expect(centralCalls()).toBe(2);
expect(desc.packages.map((p) => p.name)).toEqual(['conn-e', 'conn-d']);

// The earlier sort must not have mutated the cached catalog.
const asc = await searchPackages(base);
expect(centralCalls()).toBe(2);
expect(asc.packages.map((p) => p.name)).toEqual(['conn-a', 'conn-b']);
});

it('should share one fetch between concurrent calls', async () => {
serveCatalog();
await Promise.all([searchPackages(base), searchPackages({ ...base, offset: 2 })]);
expect(centralCalls()).toBe(2);
});

it('should re-fetch when the query or a filter changes, but not for equivalent keys', async () => {
serveCatalog();
await searchPackages(base);
expect(centralCalls()).toBe(2);

await searchPackages({ ...base, query: 'conn-a' });
expect(centralCalls()).toBe(4);

await searchPackages({ ...base, areas: ['Integration'] });
expect(centralCalls()).toBe(6);

// Same key after normalisation: trimmed query, reordered multi-select values.
await searchPackages({ ...base, query: ' conn ' });
expect(centralCalls()).toBe(6);
await searchPackages({ ...base, query: undefined, areas: ['A', 'B'] });
const afterFirst = centralCalls();
await searchPackages({ ...base, query: undefined, areas: ['B', 'A'] });
expect(centralCalls()).toBe(afterFirst);
});

it('should retry on the next call after a failed fetch instead of reusing the failure', async () => {
// Real timers: withRetry's backoff is ~1s total (same as the existing retry test).
mockFetch.mockRejectedValue(new Error('network down'));
await expect(searchPackages(base)).rejects.toThrow();
const callsAfterFailure = centralCalls();
expect(callsAfterFailure).toBeGreaterThan(0);

serveCatalog();
const result = await searchPackages(base);
expect(centralCalls()).toBeGreaterThan(callsAfterFailure);
expect(result.count).toBe(5);
});
});

describe('fetchWithTimeout (via fetchPackageVersionsNoRetry)', () => {
beforeEach(() => {
jest.useFakeTimers();
Expand Down
101 changes: 83 additions & 18 deletions src/lib/rest-client/rest-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -650,6 +650,86 @@ async function getTotalHiddenCount(orgName?: string): Promise<number> {
return promise;
}

/**
* Session cache for the full-fetch path in searchPackages. Every page click, sort
* change and re-render re-enters searchPackages with the same filters, and without
* this each one re-ran the entire count + 500-item-batch fetch just to slice a
* different page. The cached value is the merged/deduped/filtered catalog, keyed by
* what actually changes Central's request (query, areas, vendors, types, orgName) --
* deliberately NOT by sort/offset/limit, which are applied in memory per call.
* In-flight promises are cached too so concurrent callers share one fetch, and a
* failed fetch evicts its own entry so the next call retries.
*
* The cached array is shared: callers must treat it as read-only (copy before sorting).
*/
const FETCHED_CATALOG_TTL = 12 * 60 * 1000; // 12 minutes
interface FetchedCatalogEntry {
promise: Promise<BallerinaPackage[]>;
timestamp: number;
}
const fetchedCatalogCache = new Map<string, FetchedCatalogEntry>();

/** Test-only: clears the in-memory fetched-catalog cache so test cases don't leak state. */
export function __resetFetchedCatalogCacheForTests(): void {
fetchedCatalogCache.clear();
}

function getFetchedCatalogCacheKey(params: SearchParams): string {
const sortedCopy = (values?: string[]) => [...(values ?? [])].sort();
return JSON.stringify([
params.orgName ?? 'all',
(params.query ?? '').trim(),
sortedCopy(params.areas),
sortedCopy(params.vendors),
sortedCopy(params.types),
]);
}

async function fetchCatalog(params: SearchParams): Promise<BallerinaPackage[]> {
// Sort is pinned for the fetch itself so the cached order (which breaks ties in
// the per-call in-memory sort) doesn't depend on whichever sort happened to load first.
const combinations = generateFilterCombinations({ ...params, sort: 'pullCount-desc' });
const perComboPackages = await Promise.all(combinations.map(fetchAllForCombination));

// Merge and deduplicate by name-version (combinations can overlap)
const packageMap = new Map<string, BallerinaPackage>();
perComboPackages.flat().forEach((pkg) => {
const key = `${pkg.name}-${pkg.version}`;
if (!packageMap.has(key)) {
packageMap.set(key, pkg);
}
});
const merged = Array.from(packageMap.values());

// filterByExactKeywords uses the original params (not a single combo) so OR
// semantics across multi-select values are preserved.
const visible = excludeHidden(merged);
const exactMatches = filterByExactKeywords(visible, params);
return filterByRelevance(exactMatches, params.query);
}

function getFetchedCatalog(params: SearchParams): Promise<BallerinaPackage[]> {
const cacheKey = getFetchedCatalogCacheKey(params);
const cached = fetchedCatalogCache.get(cacheKey);
if (cached && Date.now() - cached.timestamp < FETCHED_CATALOG_TTL) {
return cached.promise;
}

const entry: FetchedCatalogEntry = {
promise: fetchCatalog(params).catch((error) => {
// Evict on failure (only if still ours) so the next call retries instead of
// reusing a rejected promise; the error still propagates to current callers.
if (fetchedCatalogCache.get(cacheKey) === entry) {
fetchedCatalogCache.delete(cacheKey);
}
throw error;
}),
timestamp: Date.now(),
};
fetchedCatalogCache.set(cacheKey, entry);
return entry.promise;
}

/**
* Search packages with server-side filtering, sorting, and pagination.
* Handles OR logic across multi-select filters by making multiple API calls.
Expand Down Expand Up @@ -681,27 +761,12 @@ export async function searchPackages(params: SearchParams): Promise<SearchRespon
hasKeywordFilters;

if (needsFullFetch) {
const perComboPackages = await Promise.all(combinations.map(fetchAllForCombination));

// Merge and deduplicate by name-version (combinations can overlap)
const packageMap = new Map<string, BallerinaPackage>();
perComboPackages.flat().forEach((pkg) => {
const key = `${pkg.name}-${pkg.version}`;
if (!packageMap.has(key)) {
packageMap.set(key, pkg);
}
});
const merged = Array.from(packageMap.values());

// filterByExactKeywords uses the original params (not a single combo) so OR
// semantics across multi-select values are preserved.
const visible = excludeHidden(merged);
const exactMatches = filterByExactKeywords(visible, params);
const filtered = filterByRelevance(exactMatches, params.query);
const filtered = await getFetchedCatalog(params);
// Only pullCount-desc actually reads ranking data -- skip the fetch
// entirely for every other sort to avoid an unnecessary network request.
const rankingData = params.sort === 'pullCount-desc' ? await loadRankingData() : {};
const sorted = sortMergedPackages(filtered, params.sort, params.query, rankingData);
// Copy: the cached catalog is shared and must never be sorted/mutated in place.
const sorted = sortMergedPackages([...filtered], params.sort, params.query, rankingData);
const paged = sorted.slice(params.offset, params.offset + params.limit);
return {
packages: paged,
Expand Down
Loading