fix: avoid eager publisher page hydration (#2129)

This commit is contained in:
Vyctor H. Brzezowski
2026-05-09 19:43:50 -04:00
committed by GitHub
parent 91d962e8b7
commit 77692d1249
2 changed files with 183 additions and 43 deletions
+112
View File
@@ -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<string, unknown> = {};
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",
+71 -43
View File
@@ -61,6 +61,11 @@ type PublisherListItem = NonNullable<ReturnType<typeof toPublicPublisher>> & {
}>;
};
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<QueryCtx, "db">,
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<QueryCtx, "db">, 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<MutationCtx, "db">,
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,
};
},
});