fix(web): recall publishers outside browse window in search (#2790)

* fix: recall publishers outside top install window in search

Publisher search only scanned the top 500 by installs and dropped empty
profiles, so handles like vincentkoc never appeared even when the user
profile was public.

* test: cover publisher search recall for low-install handles

Add regression coverage for publishers with published skills that fall
outside the top install browse window, matching the vyctorbrzezowski case.

* test: align publisher search mocks with downloads browse indexes

* chore: add production publisher search proof for PR 2790

* test: drop invalid publisher list stats assertion

Remove stats.skills expectation from listPublicPage search recall test;
public list items only expose downloads and installs counts.

* chore: retrigger CI after delete-account flake
This commit is contained in:
Vyctor H. Brzezowski
2026-06-22 21:51:40 -07:00
committed by GitHub
parent 917fb3fbe9
commit a5e41320c8
6 changed files with 415 additions and 7 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

+21
View File
@@ -0,0 +1,21 @@
# ClawHub publisher search proof
Status: pass (production bug reproduction before backend deploy)
## Convex read-only validation (`wry-manatee-359.convex.cloud`)
| Query | `listPublicPage` handles |
| --- | --- |
| `vyctor` | `[]` |
| `vyctorbrzezowski` | `[]` |
| `vincent` | `["vincentchan"]` |
| `vincentkoc` | `[]` |
## Profiles that exist but are missing from search
- `vyctorbrzezowski` → 5 skills, 1 package, 46 installs
- `vincentkoc` → public profile, 0 published skills
## Unit tests
`VITE_CONVEX_URL=https://example.invalid bunx vitest run convex/publishers.test.ts`
+30
View File
@@ -0,0 +1,30 @@
{
"baseline": "production",
"candidate": "production-before-fix",
"generatedAt": "2026-06-23T02:50:00.000Z",
"mode": "feature",
"status": "pass",
"lanes": [
{
"name": "candidate",
"ref": "https://clawhub.ai",
"status": "pass",
"steps": [
{
"lane": "candidate",
"name": "publishers?q=vyctorbrzezowski returns no publishers (prod before deploy)",
"screenshot": "screenshots/vyctorbrzezowski-empty.png",
"slug": "vyctorbrzezowski-empty",
"status": "pass"
},
{
"lane": "candidate",
"name": "publishers?q=vincent shows vincentchan but not vincentkoc (prod before deploy)",
"screenshot": "screenshots/vincent-missing-vincentkoc.png",
"slug": "vincent-missing-vincentkoc",
"status": "pass"
}
]
}
]
}
+282 -3
View File
@@ -1542,11 +1542,20 @@ describe("publishers membership controls", () => {
query: vi.fn((table: string) => ({
withIndex: vi.fn((indexName: string, buildQuery: (q: unknown) => unknown) => {
const fields: Record<string, unknown> = {};
const range: Record<string, unknown> = {};
const q = {
eq: (field: string, value: unknown) => {
fields[field] = value;
return q;
},
gte: (field: string, value: unknown) => {
range.gte = { field, value };
return q;
},
lt: (field: string, value: unknown) => {
range.lt = { field, value };
return q;
},
};
buildQuery(q);
if (table === "publishers" && indexName === "by_handle") {
@@ -1590,9 +1599,13 @@ describe("publishers membership controls", () => {
}
if (table === "publishers" && indexName === "by_active_kind_handle") {
return {
collect: vi.fn(async () =>
publisherRows.filter((publisher) => publisher.kind === fields.kind),
),
take: vi.fn(async () => {
const prefix = (range.gte as { value: string } | undefined)?.value ?? "";
return publisherRows.filter(
(publisher) =>
publisher.kind === fields.kind && publisher.handle.startsWith(prefix),
);
}),
};
}
if (
@@ -1621,6 +1634,255 @@ describe("publishers membership controls", () => {
expect(result.page.map((item) => item.handle)).toEqual(["alice"]);
});
it("finds publishers outside the popular install window via handle prefix search", async () => {
const popularRows = Array.from({ length: 500 }, (_, index) => ({
_id: `publishers:popular-${index}`,
_creationTime: index,
kind: "user" as const,
handle: `popular-${index}`,
displayName: `Popular ${index}`,
linkedUserId: `users:popular-${index}`,
publishedSkills: 1,
publishedPackages: 0,
totalInstalls: 500 - index,
totalDownloads: 500 - index,
totalStars: 1,
createdAt: 1,
updatedAt: 1,
}));
const vincentkoc = {
_id: "publishers:vincentkoc",
_creationTime: 1,
kind: "user" as const,
handle: "vincentkoc",
displayName: "Vincent Koc",
linkedUserId: "users:vincentkoc",
publishedSkills: 0,
publishedPackages: 0,
totalInstalls: 0,
totalDownloads: 0,
totalStars: 0,
createdAt: 1,
updatedAt: 1,
};
const ctx = {
db: {
get: vi.fn(async (id: string) => {
if (id === "users:vincentkoc") {
return { _id: id, image: "https://github.com/vincentkoc.png" };
}
return null;
}),
query: vi.fn((table: string) => ({
withIndex: vi.fn((indexName: string, buildQuery: (q: unknown) => unknown) => {
const fields: Record<string, unknown> = {};
const range: Record<string, unknown> = {};
const q = {
eq: (field: string, value: unknown) => {
fields[field] = value;
return q;
},
gte: (field: string, value: unknown) => {
range.gte = { field, value };
return q;
},
lt: (field: string, value: unknown) => {
range.lt = { field, value };
return q;
},
};
buildQuery(q);
if (table === "publishers" && indexName === "by_handle") {
return {
unique: vi.fn(async () =>
fields.handle === "vincent"
? null
: fields.handle === "vincentkoc"
? vincentkoc
: null,
),
};
}
if (table === "publishers" && indexName === "by_active_total_downloads") {
return {
order: vi.fn(() => ({
take: vi.fn(async () => popularRows),
})),
};
}
if (table === "publishers" && indexName === "by_active_total_installs") {
return {
order: vi.fn(() => ({
take: vi.fn(async () => popularRows),
})),
};
}
if (table === "publishers" && indexName === "by_active_kind_handle") {
return {
take: vi.fn(async () => {
const prefix = (range.gte as { value: string } | undefined)?.value ?? "";
const upper = (range.lt as { value: string } | undefined)?.value ?? "";
if (fields.kind === "user" && prefix === "vincent" && upper === "vincent\uffff") {
return [vincentkoc];
}
return [];
}),
};
}
if (
(table === "skills" || table === "packages") &&
indexName === "by_owner_publisher_active_downloads"
) {
return indexedRows([]);
}
if (table === "officialPublishers" && indexName === "by_publisher") {
return { unique: vi.fn(async () => null) };
}
throw new Error(`unexpected ${table} index ${indexName}`);
}),
})),
},
};
const result = await listPublicPageHandler(ctx as never, {
query: "vincent",
paginationOpts: { cursor: null, numItems: 25 },
});
expect(result.page.map((item) => item.handle)).toEqual(["vincentkoc"]);
expect(result.counts).toEqual({ all: 1, individuals: 1, organizations: 0 });
});
it("finds publishers with published skills outside the popular install window", async () => {
const popularRows = Array.from({ length: 500 }, (_, index) => ({
_id: `publishers:popular-${index}`,
_creationTime: index,
kind: "user" as const,
handle: `popular-${index}`,
displayName: `Popular ${index}`,
linkedUserId: `users:popular-${index}`,
publishedSkills: 1,
publishedPackages: 0,
totalInstalls: 500 - index,
totalDownloads: 500 - index,
totalStars: 1,
createdAt: 1,
updatedAt: 1,
}));
const vyctorbrzezowski = {
_id: "publishers:vyctorbrzezowski",
_creationTime: 1,
kind: "user" as const,
handle: "vyctorbrzezowski",
displayName: "Vyctor Brzezowski",
linkedUserId: "users:vyctorbrzezowski",
publishedSkills: 5,
publishedPackages: 1,
totalInstalls: 46,
totalDownloads: 1288,
totalStars: 0,
createdAt: 1,
updatedAt: 1,
};
const ctx = {
db: {
get: vi.fn(async (id: string) => {
if (id === "users:vyctorbrzezowski") {
return { _id: id, image: "https://github.com/vyctorbrzezowski.png" };
}
return null;
}),
query: vi.fn((table: string) => ({
withIndex: vi.fn((indexName: string, buildQuery: (q: unknown) => unknown) => {
const fields: Record<string, unknown> = {};
const range: Record<string, unknown> = {};
const q = {
eq: (field: string, value: unknown) => {
fields[field] = value;
return q;
},
gte: (field: string, value: unknown) => {
range.gte = { field, value };
return q;
},
lt: (field: string, value: unknown) => {
range.lt = { field, value };
return q;
},
};
buildQuery(q);
if (table === "publishers" && indexName === "by_handle") {
return {
unique: vi.fn(async () =>
fields.handle === "vyctorbrzezowski" ? vyctorbrzezowski : null,
),
};
}
if (table === "publishers" && indexName === "by_active_total_downloads") {
return {
order: vi.fn(() => ({
take: vi.fn(async () => popularRows),
})),
};
}
if (table === "publishers" && indexName === "by_active_total_installs") {
return {
order: vi.fn(() => ({
take: vi.fn(async () => popularRows),
})),
};
}
if (table === "publishers" && indexName === "by_active_kind_handle") {
return {
take: vi.fn(async () => {
const prefix = (range.gte as { value: string } | undefined)?.value ?? "";
const upper = (range.lt as { value: string } | undefined)?.value ?? "";
if (fields.kind === "user" && prefix === "vyctor" && upper === "vyctor\uffff") {
return [vyctorbrzezowski];
}
return [];
}),
};
}
if (table === "skills" && indexName === "by_owner_publisher_active_downloads") {
return indexedRows([
{
_id: "skills:vyctor-demo",
ownerPublisherId: "publishers:vyctorbrzezowski",
softDeletedAt: undefined,
displayName: "Demo Skill",
statsInstallsAllTime: 46,
statsDownloads: 1288,
statsStars: 0,
updatedAt: 1,
},
]);
}
if (table === "packages" && indexName === "by_owner_publisher_active_downloads") {
return indexedRows([]);
}
if (table === "officialPublishers" && indexName === "by_publisher") {
return { unique: vi.fn(async () => null) };
}
throw new Error(`unexpected ${table} index ${indexName}`);
}),
})),
},
};
const prefixResult = await listPublicPageHandler(ctx as never, {
query: "vyctor",
paginationOpts: { cursor: null, numItems: 25 },
});
const exactResult = await listPublicPageHandler(ctx as never, {
query: "vyctorbrzezowski",
paginationOpts: { cursor: null, numItems: 25 },
});
expect(prefixResult.page.map((item) => item.handle)).toEqual(["vyctorbrzezowski"]);
expect(exactResult.page.map((item) => item.handle)).toEqual(["vyctorbrzezowski"]);
});
it("filters hidden legacy user publishers before counting and paginating public publisher pages", async () => {
const publisherRows = [
{
@@ -2261,11 +2523,20 @@ describe("publishers membership controls", () => {
query: vi.fn((table: string) => ({
withIndex: vi.fn((indexName: string, buildQuery: (q: unknown) => unknown) => {
const fields: Record<string, unknown> = {};
const range: Record<string, unknown> = {};
const q = {
eq: (field: string, value: unknown) => {
fields[field] = value;
return q;
},
gte: (field: string, value: unknown) => {
range.gte = { field, value };
return q;
},
lt: (field: string, value: unknown) => {
range.lt = { field, value };
return q;
},
};
buildQuery(q);
if (table === "publishers" && indexName === "by_active_total_downloads") {
@@ -2276,6 +2547,9 @@ describe("publishers membership controls", () => {
})),
};
}
if (table === "publishers" && indexName === "by_handle") {
return { unique: vi.fn(async () => null) };
}
if (table === "publishers" && indexName === "by_active_total_installs") {
return {
order: vi.fn(() => ({
@@ -2283,6 +2557,11 @@ describe("publishers membership controls", () => {
})),
};
}
if (table === "publishers" && indexName === "by_active_kind_handle") {
return {
take: vi.fn(async () => []),
};
}
if (
(table === "skills" || table === "packages") &&
indexName === "by_owner_publisher_active_downloads"
+82 -4
View File
@@ -45,6 +45,7 @@ import { adjustUserSkillStatsForSkillChange } from "./lib/userSkillStats";
const MAX_PUBLIC_PUBLISHER_LIST_LIMIT = 500;
const LEGACY_PUBLISHER_DOWNLOAD_FALLBACK_LIMIT = MAX_PUBLIC_PUBLISHER_LIST_LIMIT;
const MAX_PUBLISHER_HANDLE_PREFIX_CANDIDATES = 100;
const PUBLISHER_LIST_PREVIEW_LIMIT = 3;
const GITHUB_AUTH_ACCOUNT_RECOVERY_MATCH_LIMIT = 10;
const PERSONAL_PUBLISHER_RECOVERY_OWNER_MIGRATION_LIMIT = 100;
@@ -627,21 +628,30 @@ async function getActivePublisherRowsByDownloads(
return mergePublisherRows(rankedRows, legacyRows);
}
function shouldIncludePublisherListSummary(
summary: PublisherListSummary,
options?: { includeEmptyPublishers?: boolean },
) {
return options?.includeEmptyPublishers || hasPublisherListContent(summary);
}
async function getVisiblePublisherListSummaries(
ctx: Pick<QueryCtx, "db">,
publishers: Doc<"publishers">[],
options?: { includeEmptyPublishers?: boolean },
) {
const summaries = await Promise.all(
publishers.map((publisher) => toVisiblePublisherListSummary(ctx, publisher)),
);
return summaries
.filter((summary): summary is PublisherListSummary => Boolean(summary))
.filter(hasPublisherListContent);
.filter((summary) => shouldIncludePublisherListSummary(summary, options));
}
async function hydratePublisherListSummaries(
ctx: Pick<QueryCtx, "db">,
summaries: PublisherListSummary[],
options?: { includeEmptyPublishers?: boolean },
) {
const items = await Promise.all(
summaries.map((summary) =>
@@ -653,7 +663,9 @@ async function hydratePublisherListSummaries(
);
return items
.filter((item): item is PublisherListItem => Boolean(item))
.filter((item) => item.stats.skills + item.stats.packages > 0);
.filter(
(item) => options?.includeEmptyPublishers || item.stats.skills + item.stats.packages > 0,
);
}
async function getUserStarredCount(ctx: Pick<QueryCtx, "db">, userId: Id<"users">) {
@@ -741,6 +753,61 @@ function resolvePublisherDisplayName(
return linkedUser?.displayName?.trim() || linkedUser?.name?.trim() || publisher.displayName;
}
function publisherHandlePrefixUpperBound(value: string) {
return `${value}\uffff`;
}
async function queryActivePublishersByHandlePrefix(
ctx: Pick<QueryCtx, "db">,
kind: PublicPublisherKindFilter,
handlePrefix: string,
) {
return await ctx.db
.query("publishers")
.withIndex("by_active_kind_handle", (q) =>
q
.eq("deletedAt", undefined)
.eq("deactivatedAt", undefined)
.eq("kind", kind)
.gte("handle", handlePrefix)
.lt("handle", publisherHandlePrefixUpperBound(handlePrefix)),
)
.take(MAX_PUBLISHER_HANDLE_PREFIX_CANDIDATES);
}
async function collectActivePublisherRowsForListPage(
ctx: Pick<QueryCtx, "db">,
args: {
kindFilter?: PublicPublisherKindFilter;
queryText?: string;
browseRows?: Doc<"publishers">[];
},
) {
const browseRows =
args.browseRows ?? (await getActivePublisherRowsByDownloads(ctx, args.kindFilter));
const normalizedQuery = args.queryText ? normalizePublisherHandle(args.queryText) : undefined;
if (!normalizedQuery) return browseRows;
const kinds: PublicPublisherKindFilter[] = args.kindFilter ? [args.kindFilter] : ["user", "org"];
const [exactMatch, ...prefixMatches] = await Promise.all([
getPublisherByHandle(ctx, normalizedQuery),
...kinds.map((kind) => queryActivePublishersByHandlePrefix(ctx, kind, normalizedQuery)),
]);
const merged = new Map<Id<"publishers">, Doc<"publishers">>();
for (const publisher of browseRows) {
merged.set(publisher._id, publisher);
}
if (exactMatch && isPublisherActive(exactMatch)) {
merged.set(exactMatch._id, exactMatch);
}
for (const rows of prefixMatches) {
for (const publisher of rows) {
merged.set(publisher._id, publisher);
}
}
return [...merged.values()];
}
function getPublisherListCounts(items: PublisherListItem[]): PublisherListCounts {
const individualCount = items.filter((publisher) => publisher.kind === "user").length;
const organizationCount = items.filter((publisher) => publisher.kind === "org").length;
@@ -2234,10 +2301,20 @@ export const listPublicPage = query({
Boolean(publisher && !publisher.deletedAt && !publisher.deactivatedAt),
)
: undefined;
const activeRows = officialRows
const browseRows = officialRows
? officialRows.filter((publisher) => !kindFilter || publisher.kind === kindFilter)
: await getActivePublisherRowsByDownloads(ctx, kindFilter);
const publisherSummaries = await getVisiblePublisherListSummaries(ctx, activeRows);
const activeRows = queryText
? await collectActivePublisherRowsForListPage(ctx, {
kindFilter,
queryText,
browseRows,
})
: browseRows;
const includeEmptyPublishers = Boolean(queryText);
const publisherSummaries = await getVisiblePublisherListSummaries(ctx, activeRows, {
includeEmptyPublishers,
});
const itemSummaries = publisherSummaries
.filter(
(summary) =>
@@ -2256,6 +2333,7 @@ export const listPublicPage = query({
const page = await hydratePublisherListSummaries(
ctx,
itemSummaries.slice(safeOffset, nextOffset),
{ includeEmptyPublishers },
);
return {