diff --git a/convex/publishers.test.ts b/convex/publishers.test.ts index 71a0270e..75ef3ef9 100644 --- a/convex/publishers.test.ts +++ b/convex/publishers.test.ts @@ -416,6 +416,118 @@ describe("publishers membership controls", () => { expect(result.page.map((item) => item.handle)).toEqual(["alice"]); }); + it("does not hydrate every publisher before filtering public publisher pages", async () => { + const publisherRows = Array.from({ length: 120 }, (_, index) => ({ + _id: `publishers:user-${index}`, + _creationTime: index, + kind: "user", + handle: `user-${index}`, + displayName: `User ${index}`, + linkedUserId: `users:user-${index}`, + publishedSkills: 1, + publishedPackages: 0, + totalInstalls: 120 - index, + totalDownloads: 120 - index, + totalStars: 1, + createdAt: 1, + updatedAt: 1, + })); + const get = vi.fn(async (id: string) => ({ _id: id, image: `https://github.com/${id}.png` })); + const ownerPublisherQueries: string[] = []; + const ctx = { + db: { + get, + query: vi.fn((table: string) => ({ + withIndex: vi.fn((indexName: string, buildQuery: (q: unknown) => unknown) => { + const fields: Record = {}; + const q = { + eq: (field: string, value: unknown) => { + fields[field] = value; + return q; + }, + }; + buildQuery(q); + if (table === "publishers" && indexName === "by_active_total_downloads") { + return { + order: vi.fn(() => ({ + take: vi.fn(async () => publisherRows), + })), + }; + } + if ( + (table === "skills" || table === "packages") && + indexName === "by_owner_publisher_active_updated" + ) { + ownerPublisherQueries.push(String(fields.ownerPublisherId)); + return { collect: vi.fn(async () => []) }; + } + throw new Error(`unexpected ${table} index ${indexName}`); + }), + })), + }, + }; + + const result = await listPublicPageHandler(ctx as never, { + paginationOpts: { cursor: null, numItems: 1 }, + }); + + expect(result.page.map((item) => item.handle)).toEqual(["user-0"]); + expect(result.globalCounts).toEqual({ all: 120, individuals: 120, organizations: 0 }); + expect(get).toHaveBeenCalledTimes(1); + expect(get).toHaveBeenCalledWith("users:user-0"); + expect(ownerPublisherQueries).toEqual(["publishers:user-0", "publishers:user-0"]); + }); + + it("does not hydrate publishers when a public publisher search has no matches", async () => { + const publisherRows = Array.from({ length: 120 }, (_, index) => ({ + _id: `publishers:user-${index}`, + _creationTime: index, + kind: "user", + handle: `user-${index}`, + displayName: `User ${index}`, + linkedUserId: `users:user-${index}`, + publishedSkills: 1, + publishedPackages: 0, + totalInstalls: 120 - index, + totalDownloads: 120 - index, + totalStars: 1, + createdAt: 1, + updatedAt: 1, + })); + const get = vi.fn(); + const ctx = { + db: { + get, + query: vi.fn((table: string) => ({ + withIndex: vi.fn((indexName: string, buildQuery: (q: unknown) => unknown) => { + const q = { + eq: () => q, + }; + buildQuery(q); + if (table === "publishers" && indexName === "by_active_total_downloads") { + return { + order: vi.fn(() => ({ + take: vi.fn(async () => publisherRows), + })), + }; + } + throw new Error(`unexpected ${table} index ${indexName}`); + }), + })), + }, + }; + + const result = await listPublicPageHandler(ctx as never, { + query: "no matching publisher", + paginationOpts: { cursor: null, numItems: 25 }, + }); + + expect(result.page).toEqual([]); + expect(result.counts).toEqual({ all: 0, individuals: 0, organizations: 0 }); + expect(result.globalCounts).toEqual({ all: 120, individuals: 120, organizations: 0 }); + expect(get).not.toHaveBeenCalled(); + }); + it("builds scoped plugin profile links with route segments", async () => { const publisher = { _id: "publishers:openclaw", diff --git a/convex/publishers.ts b/convex/publishers.ts index a74d0d9c..ba4016a2 100644 --- a/convex/publishers.ts +++ b/convex/publishers.ts @@ -61,6 +61,11 @@ type PublisherListItem = NonNullable> & { }>; }; +type PublisherListSummary = { + publisher: Doc<"publishers">; + item: PublisherListItem; +}; + type PublicPublisherKindFilter = "user" | "org"; type PublisherListCounts = { all: number; @@ -294,6 +299,38 @@ async function toPublisherListItem( }; } +function toPublisherListSummary(publisher: Doc<"publishers">): PublisherListSummary | null { + const publicPublisher = toPublicPublisher(publisher); + if (!publicPublisher) return null; + return { + publisher, + item: { + ...publicPublisher, + stats: getPublisherDenormalizedStats(publisher), + publishedItems: [], + }, + }; +} + +function hasPublisherListContent(summary: PublisherListSummary) { + if (!hasPublisherStats(summary.publisher)) return true; + return summary.item.stats.skills + summary.item.stats.packages > 0; +} + +async function hydratePublisherListSummaries( + ctx: Pick, + summaries: PublisherListSummary[], +) { + const items = await Promise.all( + summaries.map((summary) => + toPublisherListItem(ctx, summary.publisher, { includePublishedItems: true }), + ), + ); + return items + .filter((item): item is PublisherListItem => Boolean(item)) + .filter((item) => item.stats.skills + item.stats.packages > 0); +} + async function getUserStarredCount(ctx: Pick, userId: Id<"users">) { return ( await ctx.db @@ -371,17 +408,6 @@ function matchesPublisherQuery(publisher: PublisherListItem, queryText: string) return haystack.includes(queryText); } -function filterPublisherListItems( - publishers: PublisherListItem[], - args: { kind?: PublicPublisherKindFilter; query?: string }, -) { - const queryText = args.query?.trim().toLowerCase() ?? ""; - return publishers.filter((publisher) => { - if (args.kind && publisher.kind !== args.kind) return false; - return matchesPublisherQuery(publisher, queryText); - }); -} - function getPublisherListCounts(items: PublisherListItem[]): PublisherListCounts { const individualCount = items.filter((publisher) => publisher.kind === "user").length; const organizationCount = items.filter((publisher) => publisher.kind === "org").length; @@ -392,6 +418,10 @@ function getPublisherListCounts(items: PublisherListItem[]): PublisherListCounts }; } +function getPublisherListSummaryCounts(summaries: PublisherListSummary[]): PublisherListCounts { + return getPublisherListCounts(summaries.map((summary) => summary.item)); +} + async function resolveAvailableUserHandle( ctx: Pick, baseHandle: string, @@ -994,47 +1024,45 @@ export const listPublicPage = query({ ) .order("desc") .take(MAX_PUBLIC_PUBLISHER_LIST_LIMIT); - const publisherItems = ( - await Promise.all( - activeRows.map((publisher) => - toPublisherListItem(ctx, publisher, { includePublishedItems: true }), - ), + const publisherSummaries = activeRows + .map(toPublisherListSummary) + .filter((summary): summary is PublisherListSummary => Boolean(summary)) + .filter(hasPublisherListContent); + const itemSummaries = publisherSummaries + .filter( + (summary) => + (!kindFilter || summary.item.kind === kindFilter) && + matchesPublisherQuery(summary.item, queryText?.toLowerCase() ?? ""), ) - ) - .filter((item): item is PublisherListItem => Boolean(item)) - .filter((item) => item.stats.skills + item.stats.packages > 0); - const items = filterPublisherListItems(publisherItems, { - kind: kindFilter, - query: queryText, - }).sort(comparePublisherListItems); - const globalPublisherItems = kindFilter + .sort((a, b) => comparePublisherListItems(a.item, b.item)); + const globalPublisherSummaries = kindFilter ? ( - await Promise.all( - ( - await ctx.db - .query("publishers") - .withIndex("by_active_total_downloads", (q) => - q.eq("deletedAt", undefined).eq("deactivatedAt", undefined), - ) - .order("desc") - .take(MAX_PUBLIC_PUBLISHER_LIST_LIMIT) - ).map((publisher) => toPublisherListItem(ctx, publisher)), - ) + await ctx.db + .query("publishers") + .withIndex("by_active_total_downloads", (q) => + q.eq("deletedAt", undefined).eq("deactivatedAt", undefined), + ) + .order("desc") + .take(MAX_PUBLIC_PUBLISHER_LIST_LIMIT) ) - .filter((item): item is PublisherListItem => Boolean(item)) - .filter((item) => item.stats.skills + item.stats.packages > 0) - : publisherItems; - const globalCounts = getPublisherListCounts(globalPublisherItems); - const counts = queryText ? getPublisherListCounts(items) : globalCounts; + .map(toPublisherListSummary) + .filter((summary): summary is PublisherListSummary => Boolean(summary)) + .filter(hasPublisherListContent) + : publisherSummaries; + const globalCounts = getPublisherListSummaryCounts(globalPublisherSummaries); + const counts = queryText ? getPublisherListSummaryCounts(itemSummaries) : globalCounts; const nextOffset = safeOffset + numItems; - const page = items.slice(safeOffset, nextOffset); + const page = await hydratePublisherListSummaries( + ctx, + itemSummaries.slice(safeOffset, nextOffset), + ); return { page, counts, globalCounts, - continueCursor: nextOffset < items.length ? String(nextOffset) : "", - isDone: nextOffset >= items.length, + continueCursor: nextOffset < itemSummaries.length ? String(nextOffset) : "", + isDone: nextOffset >= itemSummaries.length, }; }, });