diff --git a/.proof/candidate/screenshots/vincent-missing-vincentkoc.png b/.proof/candidate/screenshots/vincent-missing-vincentkoc.png new file mode 100644 index 00000000..ea3f6e0b Binary files /dev/null and b/.proof/candidate/screenshots/vincent-missing-vincentkoc.png differ diff --git a/.proof/candidate/screenshots/vyctorbrzezowski-empty.png b/.proof/candidate/screenshots/vyctorbrzezowski-empty.png new file mode 100644 index 00000000..d9bd0d21 Binary files /dev/null and b/.proof/candidate/screenshots/vyctorbrzezowski-empty.png differ diff --git a/.proof/report.md b/.proof/report.md new file mode 100644 index 00000000..b1df5c3d --- /dev/null +++ b/.proof/report.md @@ -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` diff --git a/.proof/summary.json b/.proof/summary.json new file mode 100644 index 00000000..a8c5577f --- /dev/null +++ b/.proof/summary.json @@ -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" + } + ] + } + ] +} diff --git a/convex/publishers.test.ts b/convex/publishers.test.ts index b746e5a5..7bffeba0 100644 --- a/convex/publishers.test.ts +++ b/convex/publishers.test.ts @@ -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 = {}; + const range: Record = {}; 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 = {}; + const range: Record = {}; + 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 = {}; + const range: Record = {}; + 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 = {}; + const range: Record = {}; 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" diff --git a/convex/publishers.ts b/convex/publishers.ts index 394f061c..b6711ea4 100644 --- a/convex/publishers.ts +++ b/convex/publishers.ts @@ -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, 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, 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, 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, + 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, + 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, 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 {