mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 00:47:57 +00:00
fix: load official skills from curated index (#3314)
This commit is contained in:
@@ -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",
|
||||
|
||||
+74
-4
@@ -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<PublicListSort, number> = {
|
||||
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<OfficialFirstSkillCategoryPageOptions, "categorySlug"> & {
|
||||
categorySlug: ServerSkillCategorySlug | null;
|
||||
officialOnly?: boolean;
|
||||
createdAfter?: number;
|
||||
};
|
||||
|
||||
async function listCuratedSkillPage(
|
||||
ctx: QueryCtx,
|
||||
opts: OfficialFirstSkillCategoryPageOptions & { cursor: string | null },
|
||||
opts: CuratedSkillPageOptions & { cursor: string | null },
|
||||
): Promise<PublicSkillListPage> {
|
||||
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<PublicSkillListPage> {
|
||||
const items: PublicSkillEntry[] = [];
|
||||
if (opts.state.phase === "curated") {
|
||||
const curatedPage = await listCuratedSkillCategoryPage(ctx, {
|
||||
const curatedPage = await listCuratedSkillPage(ctx, {
|
||||
...opts,
|
||||
cursor: opts.state.cursor,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user