diff --git a/convex/skills.publicListCursor.test.ts b/convex/skills.publicListCursor.test.ts index 6d202f60..1ca36bf4 100644 --- a/convex/skills.publicListCursor.test.ts +++ b/convex/skills.publicListCursor.test.ts @@ -333,18 +333,246 @@ describe("public skill list deterministic cursors", () => { ], }); - const result = await listPublicPageV4Handler({} as never, { - officialOnly: true, - createdAfter: 100, - sort: "newest", - numItems: 10, - }); + const result = await listPublicPageV4Handler( + makeOfficialFirstCategoryCtx([officialNew, communityNew, officialOld]) as never, + { + officialOnly: true, + createdAfter: 100, + sort: "newest", + numItems: 10, + }, + ); expect( (result.page as Array<{ skill: { slug: string } }>).map((entry) => entry.skill.slug), ).toEqual(["official-new"]); }); + it("reads Official pages from the indexed curated projection", async () => { + const official = makeSearchDigest({ + skillId: "skills:official", + slug: "official", + badges: { official: { byUserId: "users:admin", at: 2 } }, + createdAt: 2, + updatedAt: 2, + }); + const highlighted = makeSearchDigest({ + skillId: "skills:highlighted", + slug: "highlighted", + badges: { highlighted: { byUserId: "users:admin", at: 1 } }, + createdAt: 1, + updatedAt: 1, + }); + const ctx = makeOfficialFirstCategoryCtx([official, highlighted]); + getPageMock.mockResolvedValueOnce({ + page: [official, highlighted], + hasMore: false, + indexKeys: [ + [undefined, official.createdAt, official.updatedAt, "curatedSkillSearchDigest:official"], + [ + undefined, + highlighted.createdAt, + highlighted.updatedAt, + "curatedSkillSearchDigest:highlighted", + ], + ], + }); + + const result = await listPublicPageV4Handler(ctx as never, { + officialOnly: true, + sort: "newest", + dir: "desc", + numItems: 10, + }); + + expect(getPageMock).toHaveBeenCalledTimes(1); + expect(getPageMock.mock.calls[0]?.[1]).toMatchObject({ + table: "curatedSkillSearchDigest", + index: "by_active_created", + }); + expect( + (result.page as Array<{ skill: { slug: string } }>).map((entry) => entry.skill.slug), + ).toEqual(["official"]); + }); + + it("continues through highlighted-only curated windows to reach Official rows", async () => { + const highlighted = makeSearchDigest({ + skillId: "skills:highlighted", + slug: "highlighted", + badges: { highlighted: { byUserId: "users:admin", at: 2 } }, + createdAt: 2, + updatedAt: 2, + }); + const official = makeSearchDigest({ + skillId: "skills:official", + slug: "official", + badges: { official: { byUserId: "users:admin", at: 1 } }, + createdAt: 1, + updatedAt: 1, + }); + const highlightedIndexKey = [ + undefined, + highlighted.createdAt, + highlighted.updatedAt, + "curatedSkillSearchDigest:highlighted", + ]; + const ctx = makeOfficialFirstCategoryCtx([highlighted, official]); + getPageMock + .mockResolvedValueOnce({ + page: [highlighted], + hasMore: true, + indexKeys: [highlightedIndexKey], + }) + .mockResolvedValueOnce({ + page: [official], + hasMore: false, + indexKeys: [ + [undefined, official.createdAt, official.updatedAt, "curatedSkillSearchDigest:official"], + ], + }); + + const result = await listPublicPageV4Handler(ctx as never, { + officialOnly: true, + sort: "newest", + numItems: 1, + }); + + expect( + (result.page as Array<{ skill: { slug: string } }>).map((entry) => entry.skill.slug), + ).toEqual(["official"]); + expect(getPageMock).toHaveBeenCalledTimes(2); + expect(getPageMock.mock.calls[1]?.[1]).toMatchObject({ + table: "curatedSkillSearchDigest", + startIndexKey: highlightedIndexKey, + startInclusive: false, + }); + }); + + it("tags Official projection cursors before continuing on the curated index", async () => { + const official = makeSearchDigest({ + skillId: "skills:official", + slug: "official", + badges: { official: { byUserId: "users:admin", at: 2 } }, + createdAt: 2, + updatedAt: 2, + }); + const indexKey = [ + undefined, + official.createdAt, + official.updatedAt, + "curatedSkillSearchDigest:official", + ]; + const ctx = makeOfficialFirstCategoryCtx([official]); + getPageMock + .mockResolvedValueOnce({ + page: [official], + hasMore: true, + indexKeys: [indexKey], + }) + .mockResolvedValueOnce({ page: [], hasMore: false, indexKeys: [] }); + + const first = await listPublicPageV4Handler(ctx as never, { + officialOnly: true, + sort: "newest", + numItems: 1, + }); + const second = await listPublicPageV4Handler(ctx as never, { + cursor: first.nextCursor!, + officialOnly: true, + sort: "newest", + numItems: 1, + }); + + expect(first.nextCursor).toMatch(/^skillofficial:/); + expect(second.nextCursor).toBeNull(); + expect(getPageMock.mock.calls[1]?.[1]).toMatchObject({ + table: "curatedSkillSearchDigest", + startIndexKey: indexKey, + startInclusive: false, + }); + }); + + it("keeps pre-deploy Official cursors on the legacy projection", async () => { + const indexKey = [undefined, 2, 2, "skillSearchDigest:official"]; + const legacyOfficialCursor = cursorForIndex("by_active_created", [ + { __undef: 1 }, + 2, + 2, + "skillSearchDigest:official", + ]); + + await listPublicPageV4Handler({} as never, { + cursor: legacyOfficialCursor, + officialOnly: true, + sort: "newest", + numItems: 1, + }); + + expect(getPageMock).toHaveBeenCalledTimes(1); + expect(getPageMock.mock.calls[0]?.[1]).toMatchObject({ + table: "skillSearchDigest", + startIndexKey: indexKey, + startInclusive: false, + }); + }); + + it("preserves the Official creation cutoff for non-creation sorts", async () => { + const recent = makeSearchDigest({ + skillId: "skills:recent-official", + slug: "recent-official", + badges: { official: { byUserId: "users:admin", at: 2 } }, + createdAt: 200, + updatedAt: 1, + }); + const old = makeSearchDigest({ + skillId: "skills:old-official", + slug: "old-official", + badges: { official: { byUserId: "users:admin", at: 1 } }, + createdAt: 50, + updatedAt: 2, + }); + getPageMock.mockResolvedValueOnce({ + page: [old, recent], + hasMore: false, + indexKeys: [ + [undefined, old.updatedAt, old._id], + [undefined, recent.updatedAt, recent._id], + ], + }); + + const result = await listPublicPageV4Handler( + makeOfficialFirstCategoryCtx([recent, old]) as never, + { + officialOnly: true, + createdAfter: 100, + sort: "updated", + numItems: 10, + }, + ); + + expect( + (result.page as Array<{ skill: { slug: string } }>).map((entry) => entry.skill.slug), + ).toEqual(["recent-official"]); + }); + + it("starts ascending Official creation scans at the requested cutoff", async () => { + await listPublicPageV4Handler({} as never, { + officialOnly: true, + createdAfter: 100, + sort: "newest", + dir: "asc", + numItems: 10, + }); + + expect(getPageMock).toHaveBeenCalledTimes(1); + expect(getPageMock.mock.calls[0]?.[1]).toMatchObject({ + table: "curatedSkillSearchDigest", + index: "by_active_created", + startIndexKey: [undefined, 100], + startInclusive: true, + }); + }); + it("ends descending New pagination when the creation window boundary is reached", async () => { const recent = makeSearchDigest({ skillId: "skills:recent", diff --git a/convex/skills.ts b/convex/skills.ts index d166b633..cf2ee109 100644 --- a/convex/skills.ts +++ b/convex/skills.ts @@ -5252,6 +5252,7 @@ type PublicSkillListPage = { nextCursor: string | null; }; const OFFICIAL_FIRST_SKILL_CATEGORY_CURSOR_PREFIX = "skillofficialfirst:"; +const OFFICIAL_SKILL_CURSOR_PREFIX = "skillofficial:"; const SORT_INDEX_FIELD_COUNTS: Record = { recommended: 3, @@ -5429,6 +5430,21 @@ function decodeOfficialFirstSkillCategoryCursor( } } +function encodeOfficialSkillCursor(cursor: string) { + return `${OFFICIAL_SKILL_CURSOR_PREFIX}${cursor}`; +} + +function decodeOfficialSkillCursor(raw: string | undefined) { + if (!raw) return { projection: "curated" as const, cursor: null }; + if (!raw.startsWith(OFFICIAL_SKILL_CURSOR_PREFIX)) { + // Untagged cursors were emitted by the legacy projection. Keep that session on + // the same table; fresh curated sessions tag every continuation cursor. + return { projection: "legacy" as const, cursor: raw }; + } + const cursor = raw.slice(OFFICIAL_SKILL_CURSOR_PREFIX.length); + return { projection: "curated" as const, cursor: cursor || null }; +} + /** * V4 of listPublicPage using convex-helpers `getPage()` for deterministic, * cacheable cursors. Two users requesting the same page produce identical @@ -5476,6 +5492,7 @@ export const listPublicPageV4 = query({ ? decodeOfficialFirstSkillCategoryCursor(args.cursor) : null; const publicListCursor = args.cursor; + const officialCursor = args.officialOnly ? decodeOfficialSkillCursor(args.cursor) : null; const requestedSort = normalizePublicListSort(args.sort); const dir = resolvePublicListDir(requestedSort, args.dir); const numItems = clampInt(args.numItems ?? 25, 1, MAX_PUBLIC_LIST_LIMIT); @@ -5538,6 +5555,26 @@ export const listPublicPageV4 = query({ }); } + if (args.officialOnly && officialCursor?.projection === "curated") { + const result = await listCuratedSkillPage(ctx, { + cursor: officialCursor.cursor, + sort: requestedSort, + dir, + numItems, + topic, + categorySlug, + categoryKeywords, + excludeCategoryKeywords, + nonSuspiciousOnly: args.nonSuspiciousOnly ?? false, + officialOnly: true, + createdAfter: args.createdAfter, + }); + return { + ...result, + nextCursor: result.nextCursor ? encodeOfficialSkillCursor(result.nextCursor) : null, + }; + } + if (topic && !officialFirstCursor) { return await listSkillTopicFilteredPage(ctx, { cursor: publicListCursor, @@ -7228,9 +7265,15 @@ type OfficialFirstSkillCategoryPageOptions = { nonSuspiciousOnly: boolean; }; -async function listCuratedSkillCategoryPage( +type CuratedSkillPageOptions = Omit & { + categorySlug: ServerSkillCategorySlug | null; + officialOnly?: boolean; + createdAfter?: number; +}; + +async function listCuratedSkillPage( ctx: QueryCtx, - opts: OfficialFirstSkillCategoryPageOptions & { cursor: string | null }, + opts: CuratedSkillPageOptions & { cursor: string | null }, ): Promise { const indexName = opts.nonSuspiciousOnly ? NONSUSPICIOUS_CURATED_SORT_INDEXES[opts.sort] @@ -7245,7 +7288,11 @@ async function listCuratedSkillCategoryPage( allowLegacyArray: false, }); const items: PublicSkillEntry[] = []; - let scanCursor = decodedCursor ?? eqPrefix; + const ascendingCreatedAfterCursor: IndexKey | null = + opts.sort === "newest" && opts.dir === "asc" && typeof opts.createdAfter === "number" + ? [...eqPrefix, opts.createdAfter] + : null; + let scanCursor = decodedCursor ?? ascendingCreatedAfterCursor ?? eqPrefix; let scanInclusive = !decodedCursor; let hasMore = false; let nextCursor: string | null = null; @@ -7279,6 +7326,15 @@ async function listCuratedSkillCategoryPage( const curatedDigest = result.page[index]; const cursor = result.indexKeys[index]; if ( + opts.sort === "newest" && + opts.dir === "desc" && + typeof opts.createdAfter === "number" && + curatedDigest.createdAt < opts.createdAfter + ) { + return { page: items, hasMore: false, nextCursor: null }; + } + if ( + (typeof opts.createdAfter !== "number" || curatedDigest.createdAt >= opts.createdAfter) && digestPassesPublicListFilters(curatedDigest, { topic: opts.topic, categorySlug: opts.categorySlug, @@ -7286,6 +7342,8 @@ async function listCuratedSkillCategoryPage( excludeCategoryKeywords: opts.excludeCategoryKeywords, }) ) { + // The compact projection contains official and highlighted rows but omits badge kind, + // so hydrate before applying officialOnly and appending a result. const digest = await ctx.db .query("skillSearchDigest") .withIndex("by_skill", (q) => q.eq("skillId", curatedDigest.skillId)) @@ -7293,6 +7351,8 @@ async function listCuratedSkillCategoryPage( if ( digest && isCuratedSkillDigest(digest) && + (!opts.officialOnly || isSkillCatalogOfficial(digest)) && + (typeof opts.createdAfter !== "number" || digest.createdAt >= opts.createdAfter) && (!opts.nonSuspiciousOnly || !digest.isSuspicious) && digestPassesPublicListFilters(digest, { topic: opts.topic, @@ -7306,6 +7366,16 @@ async function listCuratedSkillCategoryPage( } } if (items.length >= opts.numItems) { + const nextDigest = result.page[index + 1]; + if ( + opts.sort === "newest" && + opts.dir === "desc" && + typeof opts.createdAfter === "number" && + nextDigest && + nextDigest.createdAt < opts.createdAfter + ) { + return { page: items, hasMore: false, nextCursor: null }; + } hasMore = result.hasMore || index < result.page.length - 1; nextCursor = hasMore ? encodeIndexKey(indexName, cursor) : null; return { page: items, hasMore, nextCursor }; @@ -7432,7 +7502,7 @@ async function listOfficialFirstSkillCategoryPage( ): Promise { const items: PublicSkillEntry[] = []; if (opts.state.phase === "curated") { - const curatedPage = await listCuratedSkillCategoryPage(ctx, { + const curatedPage = await listCuratedSkillPage(ctx, { ...opts, cursor: opts.state.cursor, });