mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 08:52:21 +00:00
fix: avoid eager publisher page hydration (#2129)
This commit is contained in:
@@ -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
@@ -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,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user