Skip to content

Commit 59415d2

Browse files
fix: preserve filtered catalog pagination
1 parent 3208d3b commit 59415d2

4 files changed

Lines changed: 253 additions & 230 deletions

File tree

convex/packages.public.test.ts

Lines changed: 87 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ import {
5959
repairPackageIdentityInternal,
6060
searchForViewerInternal,
6161
searchPublic,
62+
setPackageCatalogMetadata,
6263
} from "./packages";
6364

6465
vi.mock("@convex-dev/auth/server", () => ({
@@ -443,6 +444,12 @@ const getManageContextHandler = (
443444
} | null
444445
>
445446
)._handler;
447+
const setPackageCatalogMetadataHandler = (
448+
setPackageCatalogMetadata as unknown as WrappedHandler<
449+
{ packageId: string; primaryCategory: string; topics: string[] },
450+
unknown
451+
>
452+
)._handler;
446453
const canDeleteVersionsHandler = (
447454
canDeleteVersions as unknown as WrappedHandler<
448455
{ name: string; candidateNames?: string[] },
@@ -3787,7 +3794,11 @@ describe("packages public queries", () => {
37873794
});
37883795

37893796
expect(result.map((entry) => entry.package.name)).toEqual(["calendar-demo"]);
3790-
expect(tableNames).toEqual(["packageTopicSearchDigest"]);
3797+
expect(tableNames).toEqual([
3798+
"packageSearchDigest",
3799+
"packageSearchDigest",
3800+
"packageTopicSearchDigest",
3801+
]);
37913802
expect(indexNames).toEqual(["by_active_topic_updated"]);
37923803
});
37933804

@@ -3869,6 +3880,33 @@ describe("packages public queries", () => {
38693880
expect(ctx.db.query).toHaveBeenCalledWith("packageSearchDigest");
38703881
});
38713882

3883+
it("includes older exact package-name matches in topic-filtered search", async () => {
3884+
const exactPkg = makePackageDoc({
3885+
_id: "packages:exact",
3886+
name: "demo-plugin",
3887+
normalizedName: "demo-plugin",
3888+
topics: ["calendar"],
3889+
});
3890+
const exactDigest = makeDigest("demo-plugin", {
3891+
packageId: "packages:exact",
3892+
topics: ["calendar"],
3893+
updatedAt: 1,
3894+
});
3895+
const { ctx } = makeDigestCtx({
3896+
topicPages: [],
3897+
exactPackages: [exactPkg],
3898+
exactDigests: [exactDigest],
3899+
});
3900+
3901+
const result = await searchPublicHandler(ctx, {
3902+
query: "demo-plugin",
3903+
topic: "calendar",
3904+
limit: 10,
3905+
});
3906+
3907+
expect(result.map((entry) => entry.package.name)).toEqual(["demo-plugin"]);
3908+
});
3909+
38723910
it("includes exact runtime-id matches before digest scanning", async () => {
38733911
const exactPkg = makePackageDoc({
38743912
_id: "packages:runtime",
@@ -7264,6 +7302,54 @@ describe("packages public queries", () => {
72647302
);
72657303
});
72667304

7305+
it("keeps moderator-cleared package topics explicit", async () => {
7306+
vi.mocked(getAuthUserId).mockResolvedValue("users:moderator" as never);
7307+
const pkg = makePackageDoc({
7308+
ownerPublisherId: "publishers:owner",
7309+
primaryCategory: "dev-tools",
7310+
topics: ["legacy"],
7311+
});
7312+
const existingDigest = makeDigest("demo-plugin", {
7313+
packageId: pkg._id,
7314+
primaryCategory: "dev-tools",
7315+
topics: ["legacy"],
7316+
});
7317+
const patch = vi.fn();
7318+
const ctx = {
7319+
db: {
7320+
get: vi.fn(async (id: string) => {
7321+
if (id === "users:moderator") return { _id: id, role: "moderator" };
7322+
if (id === pkg._id) return pkg;
7323+
if (id === "publishers:owner") {
7324+
return { _id: id, kind: "org", handle: "owner", displayName: "Owner" };
7325+
}
7326+
return null;
7327+
}),
7328+
query: vi.fn((table: string) => ({
7329+
withIndex: vi.fn(() => ({
7330+
unique: vi
7331+
.fn()
7332+
.mockResolvedValue(table === "packageSearchDigest" ? existingDigest : null),
7333+
collect: vi.fn().mockResolvedValue([]),
7334+
})),
7335+
})),
7336+
patch,
7337+
insert: vi.fn().mockResolvedValue("auditLogs:1"),
7338+
delete: vi.fn(),
7339+
replace: vi.fn(),
7340+
normalizeId: vi.fn(),
7341+
},
7342+
};
7343+
7344+
await setPackageCatalogMetadataHandler(ctx as never, {
7345+
packageId: pkg._id as string,
7346+
primaryCategory: "dev-tools",
7347+
topics: [],
7348+
});
7349+
7350+
expect(patch).toHaveBeenCalledWith(pkg._id, expect.objectContaining({ topics: [] }));
7351+
});
7352+
72677353
it("blocks plugin publishes when plugin inspector reports hard breakages", async () => {
72687354
const runMutation = vi.fn(async (_ref: unknown, args: unknown) => {
72697355
if (typeof args === "object" && args !== null && "minimumRole" in args) return null;

convex/packages.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3929,7 +3929,7 @@ async function searchPackagesImpl(
39293929
const matches: Array<PackageSearchMatch & { package: PublicPackageListItem }> = [];
39303930
const seen = new Set<string>();
39313931
const directDigests =
3932-
args.capabilityTag || args.category || topic
3932+
args.capabilityTag || args.category
39333933
? []
39343934
: await resolveDirectPackageSearchDigests(ctx, queryText);
39353935
for (const digest of directDigests) {
@@ -7798,7 +7798,7 @@ export const setPackageCatalogMetadata = mutation({
77987798
const nextPackage = {
77997799
...pkg,
78007800
primaryCategory: args.primaryCategory,
7801-
topics: normalizedTopics.length ? normalizedTopics : undefined,
7801+
topics: normalizedTopics,
78027802
updatedAt: now,
78037803
};
78047804
await ctx.db.patch(pkg._id, {

convex/skills.publicListCursor.test.ts

Lines changed: 101 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -136,79 +136,23 @@ function makeSearchDigest(overrides: Record<string, unknown> = {}) {
136136
}
137137

138138
function makeOfficialFirstCategoryCtx(
139-
curatedDigests: Array<ReturnType<typeof makeSearchDigest>>,
140-
officialBadgePages: Array<Array<{ skillId: string }>> = [
141-
curatedDigests.map((digest) => ({ skillId: digest.skillId })),
142-
],
143139
publicDigestPages: Array<{
144140
page: Array<ReturnType<typeof makeSearchDigest>>;
145141
hasMore: boolean;
146142
indexKeys: unknown[][];
147-
}> = [],
143+
}>,
148144
) {
149-
const digestsBySkillId = new Map(curatedDigests.map((digest) => [digest.skillId, digest]));
150145
let publicDigestPageIndex = 0;
151-
getPageMock.mockImplementation(
152-
async (
153-
_ctx: unknown,
154-
request: {
155-
table: string;
156-
endIndexKey?: unknown[];
157-
startIndexKey?: unknown[];
158-
},
159-
) => {
160-
if (request.table !== "skillBadges") {
161-
return (
162-
publicDigestPages[publicDigestPageIndex++] ?? {
163-
page: [],
164-
hasMore: false,
165-
indexKeys: [],
166-
}
167-
);
146+
getPageMock.mockImplementation(async () => {
147+
return (
148+
publicDigestPages[publicDigestPageIndex++] ?? {
149+
page: [],
150+
hasMore: false,
151+
indexKeys: [],
168152
}
169-
const kind = request.endIndexKey?.[0];
170-
const pageIndex =
171-
request.startIndexKey && request.startIndexKey.length > 1
172-
? Number(request.startIndexKey[1]) + 1
173-
: 0;
174-
const pages = kind === "official" ? officialBadgePages : [[]];
175-
const page = pages[pageIndex] ?? [];
176-
return {
177-
page,
178-
hasMore: pageIndex < pages.length - 1,
179-
indexKeys: page.map((badge, index) => [kind, pageIndex, index, badge.skillId]),
180-
};
181-
},
182-
);
183-
return {
184-
db: {
185-
query: vi.fn((table: string) => {
186-
if (table === "skillSearchDigest") {
187-
return {
188-
withIndex: vi.fn(
189-
(
190-
_indexName: string,
191-
builder: (q: { eq: (field: string, value: string) => unknown }) => unknown,
192-
) => {
193-
let skillId = "";
194-
const queryBuilder = {
195-
eq: (_field: string, value: string) => {
196-
skillId = value;
197-
return queryBuilder;
198-
},
199-
};
200-
builder(queryBuilder);
201-
return {
202-
unique: vi.fn().mockResolvedValue(digestsBySkillId.get(skillId) ?? null),
203-
};
204-
},
205-
),
206-
};
207-
}
208-
throw new Error(`Unexpected table: ${table}`);
209-
}),
210-
},
211-
};
153+
);
154+
});
155+
return { db: { query: vi.fn() } };
212156
}
213157

214158
function legacyCursor(key: unknown[]): string {
@@ -522,7 +466,15 @@ describe("public skill list deterministic cursors", () => {
522466
primaryCategory: "dev-tools",
523467
updatedAt: 2,
524468
});
525-
const ctx = makeOfficialFirstCategoryCtx([official], undefined, [
469+
const ctx = makeOfficialFirstCategoryCtx([
470+
{
471+
page: [community, official],
472+
hasMore: false,
473+
indexKeys: [
474+
[undefined, 2, 2, "skillSearchDigest:community-dev"],
475+
[undefined, 1, 1, "skillSearchDigest:official-dev"],
476+
],
477+
},
526478
{
527479
page: [community, official],
528480
hasMore: false,
@@ -580,7 +532,7 @@ describe("public skill list deterministic cursors", () => {
580532
expect(third.nextCursor).toBeNull();
581533
});
582534

583-
it("loads older curated badges beyond the first badge page", async () => {
535+
it("continues the bounded curated scan before community fallback", async () => {
584536
const official = makeSearchDigest({
585537
skillId: "skills:older-official-dev",
586538
slug: "older-official-dev",
@@ -596,17 +548,23 @@ describe("public skill list deterministic cursors", () => {
596548
primaryCategory: "dev-tools",
597549
updatedAt: 2,
598550
});
599-
const ctx = makeOfficialFirstCategoryCtx(
600-
[official],
601-
[[{ skillId: "skills:unrelated-newer-official" }], [{ skillId: official.skillId }]],
602-
[
603-
{
604-
page: [community],
605-
hasMore: false,
606-
indexKeys: [[undefined, 2, 2, "skillSearchDigest:newer-community-dev"]],
607-
},
608-
],
609-
);
551+
const ctx = makeOfficialFirstCategoryCtx([
552+
{
553+
page: [community],
554+
hasMore: true,
555+
indexKeys: [[undefined, 2, 2, "skillSearchDigest:newer-community-dev"]],
556+
},
557+
{
558+
page: [official],
559+
hasMore: false,
560+
indexKeys: [[undefined, 1, 1, "skillSearchDigest:older-official-dev"]],
561+
},
562+
{
563+
page: [community],
564+
hasMore: false,
565+
indexKeys: [[undefined, 2, 2, "skillSearchDigest:newer-community-dev"]],
566+
},
567+
]);
610568

611569
const result = await listPublicPageV4Handler(ctx as never, {
612570
categorySlug: "dev-tools",
@@ -620,6 +578,64 @@ describe("public skill list deterministic cursors", () => {
620578
).toEqual(["older-official-dev"]);
621579
});
622580

581+
it("resumes the curated phase after reaching the per-request scan budget", async () => {
582+
const official = makeSearchDigest({
583+
skillId: "skills:older-official-dev",
584+
slug: "older-official-dev",
585+
displayName: "Older Official Dev",
586+
primaryCategory: "dev-tools",
587+
badges: { official: { byUserId: "users:admin", at: 1 } },
588+
updatedAt: 1,
589+
});
590+
const noisePages = Array.from({ length: 4 }, (_, index) => {
591+
const updatedAt = 10 - index;
592+
return {
593+
page: [
594+
makeSearchDigest({
595+
skillId: `skills:community-${updatedAt}`,
596+
slug: `community-${updatedAt}`,
597+
displayName: `Community ${updatedAt}`,
598+
primaryCategory: "dev-tools",
599+
updatedAt,
600+
}),
601+
],
602+
hasMore: true,
603+
indexKeys: [[undefined, updatedAt, updatedAt, `skillSearchDigest:community-${updatedAt}`]],
604+
};
605+
});
606+
const ctx = makeOfficialFirstCategoryCtx([
607+
...noisePages,
608+
{
609+
page: [official],
610+
hasMore: false,
611+
indexKeys: [[undefined, 1, 1, "skillSearchDigest:older-official-dev"]],
612+
},
613+
{ page: [], hasMore: false, indexKeys: [] },
614+
]);
615+
616+
const first = await listPublicPageV4Handler(ctx as never, {
617+
categorySlug: "dev-tools",
618+
officialFirst: true,
619+
numItems: 1,
620+
sort: "updated",
621+
});
622+
expect(first.page).toEqual([]);
623+
expect(first.hasMore).toBe(true);
624+
expect(first.nextCursor).toContain("skillofficialfirst:");
625+
626+
const second = await listPublicPageV4Handler(ctx as never, {
627+
cursor: first.nextCursor!,
628+
categorySlug: "dev-tools",
629+
officialFirst: true,
630+
numItems: 1,
631+
sort: "updated",
632+
});
633+
expect(
634+
(second.page as Array<{ skill: { slug: string } }>).map((entry) => entry.skill.slug),
635+
).toEqual(["older-official-dev"]);
636+
expect(second.hasMore).toBe(false);
637+
});
638+
623639
it("does not advertise an empty community page after a full curated category page", async () => {
624640
const official = makeSearchDigest({
625641
skillId: "skills:official-dev",
@@ -629,7 +645,12 @@ describe("public skill list deterministic cursors", () => {
629645
badges: { official: { byUserId: "users:admin", at: 1 } },
630646
updatedAt: 1,
631647
});
632-
const ctx = makeOfficialFirstCategoryCtx([official], undefined, [
648+
const ctx = makeOfficialFirstCategoryCtx([
649+
{
650+
page: [official],
651+
hasMore: false,
652+
indexKeys: [[undefined, 1, 1, "skillSearchDigest:official-dev"]],
653+
},
633654
{
634655
page: [official],
635656
hasMore: false,

0 commit comments

Comments
 (0)