diff --git a/convex/packages.public.test.ts b/convex/packages.public.test.ts index 717cfe46..6afbb9c1 100644 --- a/convex/packages.public.test.ts +++ b/convex/packages.public.test.ts @@ -1825,7 +1825,10 @@ function makePluginExportIndexKey(row: Record) { return [row.softDeletedAt, row.family, row.updatedAt, row._creationTime, row._id] as unknown[]; } -function makePluginExportCtx(digests: Array>) { +function makePluginExportCtx( + digests: Array>, + options?: { recommendationScoresMissing?: () => boolean }, +) { const packagesById = new Map( digests.map((digest) => [ String(digest.packageId), @@ -1851,6 +1854,15 @@ function makePluginExportCtx(digests: Array>) { db: { get: vi.fn(async (id: string) => packagesById.get(id) ?? null), query: vi.fn((table: string) => { + if (table === "packages") { + return { + withIndex: vi.fn(() => ({ + first: vi.fn(async () => + options?.recommendationScoresMissing?.() ? { _id: "packages:missing-score" } : null, + ), + })), + }; + } if (table !== "packageSearchDigest") throw new Error(`Unexpected table ${table}`); return { withIndex: vi.fn( @@ -17008,6 +17020,36 @@ describe("restorePackageInternal", () => { } }); + it("keeps the cursor's effective sort when recommendation backfill completes between pages", async () => { + const previous = process.env.CLAWHUB_EXPERIMENTAL_CLAWS; + delete process.env.CLAWHUB_EXPERIMENTAL_CLAWS; + let recommendationScoresMissing = true; + const ctx = makePluginExportCtx( + [ + makeDigest("newer-plugin", { updatedAt: 2, _creationTime: 2 }), + makeDigest("older-plugin", { updatedAt: 1, _creationTime: 1 }), + ], + { recommendationScoresMissing: () => recommendationScoresMissing }, + ); + + try { + const first = await listPublicPageHandler(ctx, { + sort: "recommended", + paginationOpts: { cursor: null, numItems: 1 }, + }); + recommendationScoresMissing = false; + const second = await listPublicPageHandler(ctx, { + sort: "recommended", + paginationOpts: { cursor: first.continueCursor, numItems: 1 }, + }); + expect(first.page.map((entry) => entry.name)).toEqual(["newer-plugin"]); + expect(second.page.map((entry) => entry.name)).toEqual(["older-plugin"]); + } finally { + if (previous === undefined) delete process.env.CLAWHUB_EXPERIMENTAL_CLAWS; + else process.env.CLAWHUB_EXPERIMENTAL_CLAWS = previous; + } + }); + it("does not let disabled Claws starve unfiltered public search", async () => { const previous = process.env.CLAWHUB_EXPERIMENTAL_CLAWS; delete process.env.CLAWHUB_EXPERIMENTAL_CLAWS; diff --git a/convex/packages.ts b/convex/packages.ts index bcfd9328..f11ba313 100644 --- a/convex/packages.ts +++ b/convex/packages.ts @@ -3476,6 +3476,23 @@ function encodeStablePackageDiscoveryCursor(state: StablePackageDiscoveryCursorS })}`; } +function readStablePackageDiscoveryCursorSort( + raw: string | null | undefined, +): "updated" | "recommended" | null { + if (!raw?.startsWith(STABLE_PACKAGE_DISCOVERY_CURSOR_PREFIX)) return null; + try { + const parsed = JSON.parse(raw.slice(STABLE_PACKAGE_DISCOVERY_CURSOR_PREFIX.length)) as { + v?: unknown; + sort?: unknown; + }; + return parsed.v === 1 && (parsed.sort === "updated" || parsed.sort === "recommended") + ? parsed.sort + : null; + } catch { + return null; + } +} + function decodeStablePackageDiscoveryCursor( raw: string | null | undefined, sort: StablePackageDiscoveryCursorState["sort"], @@ -3930,17 +3947,21 @@ async function listStablePackageDiscoveryPage( Math.min(args.paginationOpts.numItems, MAX_PUBLIC_LIST_PAGE_SIZE), ); const requestedSort = args.sort ?? "updated"; - const sort = - requestedSort === "recommended" && - ( + const cursorSort = + requestedSort === "recommended" + ? readStablePackageDiscoveryCursorSort(args.paginationOpts.cursor) + : null; + let sort = cursorSort ?? requestedSort; + if (!cursorSort && requestedSort === "recommended") { + const recommendationScoresMissing = ( await Promise.all( STABLE_PACKAGE_FAMILIES.map( async (family) => await hasMissingPackageRecommendedScore(ctx, family), ), ) - ).some(Boolean) - ? "updated" - : requestedSort; + ).some(Boolean); + if (recommendationScoresMissing) sort = "updated"; + } const cursor = decodeStablePackageDiscoveryCursor(args.paginationOpts.cursor, sort); const scanLimit = Math.min(MAX_PUBLIC_LIST_PAGE_SIZE, Math.max(targetCount * 5, 50)); const loadFamily = async (family: StablePackageFamily) => {