mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 00:47:57 +00:00
feat: order featured catalog by recency (#3168)
This commit is contained in:
@@ -135,7 +135,7 @@ function makeFeedSkillEntry(index: number) {
|
||||
function makeCtx(
|
||||
packages: unknown[],
|
||||
records: Record<string, unknown>,
|
||||
options: { packageHighlighted?: boolean } = {},
|
||||
options: { packageHighlightedAt?: number } = {},
|
||||
) {
|
||||
return {
|
||||
db: {
|
||||
@@ -149,12 +149,12 @@ function makeCtx(
|
||||
apply(query);
|
||||
return {
|
||||
unique: vi.fn(async () =>
|
||||
options.packageHighlighted
|
||||
options.packageHighlightedAt !== undefined
|
||||
? {
|
||||
packageId: "packages:1",
|
||||
kind: "highlighted",
|
||||
byUserId: "users:moderator",
|
||||
at: 1,
|
||||
at: options.packageHighlightedAt,
|
||||
}
|
||||
: null,
|
||||
),
|
||||
@@ -237,7 +237,7 @@ describe("catalog feed projection", () => {
|
||||
{
|
||||
"packageReleases:1": makeRelease(),
|
||||
},
|
||||
{ packageHighlighted: true },
|
||||
{ packageHighlightedAt: 1_784_280_000_000 },
|
||||
),
|
||||
{ family: "code-plugin" },
|
||||
);
|
||||
@@ -247,6 +247,7 @@ describe("catalog feed projection", () => {
|
||||
id: "@openclaw/demo",
|
||||
state: "available",
|
||||
featured: true,
|
||||
featuredAt: 1_784_280_000_000,
|
||||
install: {
|
||||
candidates: [
|
||||
expect.objectContaining({
|
||||
@@ -343,10 +344,19 @@ describe("catalog feed projection", () => {
|
||||
|
||||
it("projects highlighted official skills as featured install candidates", async () => {
|
||||
const result = (await listOfficialSkillEntriesHandler(
|
||||
makeCtx([makeSkill({ badges: { highlighted: { byUserId: "users:moderator", at: 1 } } })], {
|
||||
"publishers:1": { _id: "publishers:1", kind: "org", handle: "openclaw" },
|
||||
"skillVersions:1": makeSkillVersion(),
|
||||
}),
|
||||
makeCtx(
|
||||
[
|
||||
makeSkill({
|
||||
badges: {
|
||||
highlighted: { byUserId: "users:moderator", at: 1_784_280_000_000 },
|
||||
},
|
||||
}),
|
||||
],
|
||||
{
|
||||
"publishers:1": { _id: "publishers:1", kind: "org", handle: "openclaw" },
|
||||
"skillVersions:1": makeSkillVersion(),
|
||||
},
|
||||
),
|
||||
{ publisherId: "publishers:1", cursor: null },
|
||||
)) as { entries: unknown[]; isDone: boolean };
|
||||
|
||||
@@ -355,6 +365,7 @@ describe("catalog feed projection", () => {
|
||||
id: "@openclaw/demo",
|
||||
state: "available",
|
||||
featured: true,
|
||||
featuredAt: 1_784_280_000_000,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -65,6 +65,7 @@ const catalogFeedEntryFields = {
|
||||
v.literal("deprecated"),
|
||||
),
|
||||
featured: v.optional(v.boolean()),
|
||||
featuredAt: v.optional(v.number()),
|
||||
publisher: v.object({
|
||||
id: v.string(),
|
||||
trust: v.union(v.literal("official"), v.literal("community")),
|
||||
@@ -137,6 +138,7 @@ async function buildEntry(
|
||||
version,
|
||||
state: "available",
|
||||
featured: Boolean(highlighted),
|
||||
...(highlighted ? { featuredAt: highlighted.at } : {}),
|
||||
publisher: {
|
||||
id: publisherId,
|
||||
trust: "official",
|
||||
@@ -210,6 +212,7 @@ async function buildSkillEntry(
|
||||
const title = skill.displayName.trim() || slug;
|
||||
const description = skill.summary?.trim();
|
||||
const icon = skill.icon?.trim();
|
||||
const highlightedAt = skill.badges?.highlighted?.at;
|
||||
const packageName = `@${publisherId}/${slug}`;
|
||||
if (!publisherId || !slug || !title) return null;
|
||||
|
||||
@@ -243,6 +246,7 @@ async function buildSkillEntry(
|
||||
version: commit,
|
||||
state: "available",
|
||||
featured: isSkillHighlighted(skill),
|
||||
...(highlightedAt !== undefined ? { featuredAt: highlightedAt } : {}),
|
||||
publisher: {
|
||||
id: publisherId,
|
||||
trust: "official",
|
||||
@@ -290,6 +294,7 @@ async function buildSkillEntry(
|
||||
version: versionName,
|
||||
state: "available",
|
||||
featured: isSkillHighlighted(skill),
|
||||
...(highlightedAt !== undefined ? { featuredAt: highlightedAt } : {}),
|
||||
publisher: {
|
||||
id: publisherId,
|
||||
trust: "official",
|
||||
|
||||
@@ -9708,14 +9708,31 @@ describe("httpApiV1 handlers", () => {
|
||||
expect(codePluginCursors).toEqual([null, "downloads-cursor"]);
|
||||
});
|
||||
|
||||
it("plugins list defaults featured browse to downloads sort", async () => {
|
||||
const readinessCalls: unknown[] = [];
|
||||
it("plugins list preserves combined newest-featured order across plugin families", async () => {
|
||||
const newestFeatured = makeCatalogItem("newest-featured", {
|
||||
family: "bundle-plugin",
|
||||
updatedAt: 20,
|
||||
stats: { downloads: 1, installs: 1, stars: 0, versions: 1 },
|
||||
});
|
||||
const olderPopular = makeCatalogItem("older-popular", {
|
||||
family: "code-plugin",
|
||||
updatedAt: 10,
|
||||
stats: { downloads: 100, installs: 100, stars: 0, versions: 1 },
|
||||
});
|
||||
const runQuery = vi.fn((_, args: Record<string, unknown>) => {
|
||||
if (hasPluginRecommendedScoreReadinessArgs(args)) {
|
||||
readinessCalls.push(args);
|
||||
return false;
|
||||
}
|
||||
return { page: [], isDone: true, continueCursor: "" };
|
||||
expect(args).toEqual(
|
||||
expect.objectContaining({
|
||||
families: ["code-plugin", "bundle-plugin"],
|
||||
highlightedOnly: true,
|
||||
paginationOpts: { cursor: null, numItems: 7 },
|
||||
}),
|
||||
);
|
||||
expect(args).not.toHaveProperty("family");
|
||||
return {
|
||||
page: [newestFeatured, olderPopular],
|
||||
isDone: true,
|
||||
continueCursor: "",
|
||||
};
|
||||
});
|
||||
const runMutation = vi.fn().mockResolvedValue(okRate());
|
||||
|
||||
@@ -9725,17 +9742,11 @@ describe("httpApiV1 handlers", () => {
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(readinessCalls).toEqual([]);
|
||||
for (const [, args] of runQuery.mock.calls) {
|
||||
if (hasPluginRecommendedScoreReadinessArgs(args)) continue;
|
||||
expect(args).toEqual(
|
||||
expect.objectContaining({
|
||||
highlightedOnly: true,
|
||||
sort: "downloads",
|
||||
paginationOpts: { cursor: null, numItems: 7 },
|
||||
}),
|
||||
);
|
||||
}
|
||||
await expect(response.json()).resolves.toMatchObject({
|
||||
items: [{ name: "newest-featured" }, { name: "older-popular" }],
|
||||
nextCursor: null,
|
||||
});
|
||||
expect(runQuery).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("plugins list downloads sort forwards to both plugin families and merges by downloads", async () => {
|
||||
|
||||
@@ -1777,6 +1777,32 @@ async function listPackages(
|
||||
);
|
||||
}
|
||||
|
||||
if (!effectiveFamily && options?.pluginFamilies?.length && highlightedOnly) {
|
||||
const result = await runQueryRef<{
|
||||
page: CatalogListItem[];
|
||||
isDone: boolean;
|
||||
continueCursor: string | null;
|
||||
}>(ctx, internalRefs.packages.listPageForViewerInternal, {
|
||||
families: options.pluginFamilies,
|
||||
channel: channelParam.value,
|
||||
isOfficial: isOfficial.value,
|
||||
highlightedOnly: true,
|
||||
category,
|
||||
topic,
|
||||
excludedScanStatuses: excludedScanStatuses.value,
|
||||
viewerUserId: viewerUserId ?? undefined,
|
||||
paginationOpts: { cursor: rawCursor, numItems: limit },
|
||||
});
|
||||
return json(
|
||||
{
|
||||
items: result.page,
|
||||
nextCursor: result.isDone ? null : result.continueCursor,
|
||||
},
|
||||
200,
|
||||
rate.headers,
|
||||
);
|
||||
}
|
||||
|
||||
if (!effectiveFamily && options?.pluginFamilies?.length) {
|
||||
const shouldMarkDefaultDownloadCursor =
|
||||
!sortParam.value && pluginDefaultSort === RECOMMENDED_FALLBACK_SORT;
|
||||
|
||||
@@ -157,6 +157,7 @@ const listPageForViewerInternalHandler = (
|
||||
listPageForViewerInternal as unknown as WrappedHandler<
|
||||
{
|
||||
family?: "skill" | "code-plugin" | "bundle-plugin";
|
||||
families?: Array<"skill" | "code-plugin" | "bundle-plugin">;
|
||||
channel?: "official" | "community" | "private";
|
||||
isOfficial?: boolean;
|
||||
executesCode?: boolean;
|
||||
@@ -3719,31 +3720,71 @@ describe("packages public queries", () => {
|
||||
expect(result.page.map((entry) => entry.name)).toEqual(["secret-plugin", "public-plugin"]);
|
||||
});
|
||||
|
||||
it("sorts highlighted package pages by the requested install order", async () => {
|
||||
const lowerInstall = makeDigest("lower-install", {
|
||||
it("keeps highlighted package pages in newest-featured order", async () => {
|
||||
const newestFeatured = makeDigest("newest-featured", {
|
||||
updatedAt: 20,
|
||||
stats: { downloads: 100, installs: 5, stars: 0, versions: 1 },
|
||||
});
|
||||
const higherInstall = makeDigest("higher-install", {
|
||||
const olderFeatured = makeDigest("older-featured", {
|
||||
updatedAt: 10,
|
||||
stats: { downloads: 1, installs: 50, stars: 0, versions: 1 },
|
||||
});
|
||||
const newerSkill = makeDigest("newer-skill", {
|
||||
family: "skill",
|
||||
updatedAt: 30,
|
||||
});
|
||||
const { ctx } = makeDigestCtx({
|
||||
highlightedBadges: [
|
||||
{ packageId: lowerInstall.packageId },
|
||||
{ packageId: higherInstall.packageId },
|
||||
{ packageId: newerSkill.packageId, at: 300 },
|
||||
{ packageId: newestFeatured.packageId, at: 200 },
|
||||
{ packageId: olderFeatured.packageId, at: 100 },
|
||||
],
|
||||
exactDigests: [lowerInstall, higherInstall],
|
||||
exactDigests: [newerSkill, newestFeatured, olderFeatured],
|
||||
});
|
||||
|
||||
const result = await listPageForViewerInternalHandler(ctx, {
|
||||
family: "code-plugin",
|
||||
families: ["code-plugin", "bundle-plugin"],
|
||||
highlightedOnly: true,
|
||||
sort: "installs",
|
||||
paginationOpts: { cursor: null, numItems: 10 },
|
||||
});
|
||||
|
||||
expect(result.page.map((entry) => entry.name)).toEqual(["higher-install", "lower-install"]);
|
||||
expect(result.page.map((entry) => entry.name)).toEqual(["newest-featured", "older-featured"]);
|
||||
});
|
||||
|
||||
it("keeps official packages first without re-ranking featured recency within each group", async () => {
|
||||
const newestCommunity = makeDigest("newest-community", {
|
||||
isOfficial: false,
|
||||
updatedAt: 30,
|
||||
});
|
||||
const newestOfficial = makeDigest("newest-official", {
|
||||
isOfficial: true,
|
||||
updatedAt: 20,
|
||||
});
|
||||
const olderOfficial = makeDigest("older-official", {
|
||||
isOfficial: true,
|
||||
updatedAt: 10,
|
||||
});
|
||||
const { ctx } = makeDigestCtx({
|
||||
highlightedBadges: [
|
||||
{ packageId: newestCommunity.packageId, at: 300 },
|
||||
{ packageId: newestOfficial.packageId, at: 200 },
|
||||
{ packageId: olderOfficial.packageId, at: 100 },
|
||||
],
|
||||
exactDigests: [newestCommunity, newestOfficial, olderOfficial],
|
||||
});
|
||||
|
||||
const result = await listPageForViewerInternalHandler(ctx, {
|
||||
highlightedOnly: true,
|
||||
officialFirst: true,
|
||||
paginationOpts: { cursor: null, numItems: 10 },
|
||||
});
|
||||
|
||||
expect(result.page.map((entry) => entry.name)).toEqual([
|
||||
"newest-official",
|
||||
"older-official",
|
||||
"newest-community",
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not let stale personal ownerUserId expose private package digests", async () => {
|
||||
|
||||
+19
-27
@@ -108,7 +108,6 @@ import {
|
||||
MAX_PUBLISH_TOTAL_BYTES,
|
||||
} from "./lib/publishLimits";
|
||||
import {
|
||||
compareRecommendationStats,
|
||||
computeRecommendationScore,
|
||||
RECOMMENDATION_SCORE_VERSION,
|
||||
} from "./lib/recommendationScore";
|
||||
@@ -1327,6 +1326,7 @@ function digestMatchesSearchFilters(
|
||||
digest: PackageDigestLike,
|
||||
args: {
|
||||
family?: PackageFamily;
|
||||
families?: PackageFamily[];
|
||||
channel?: PackageChannel;
|
||||
isOfficial?: boolean;
|
||||
category?: string;
|
||||
@@ -1335,6 +1335,7 @@ function digestMatchesSearchFilters(
|
||||
},
|
||||
) {
|
||||
if (args.family && digest.family !== args.family) return false;
|
||||
if (args.families?.length && !args.families.includes(digest.family)) return false;
|
||||
if (args.channel && digest.channel !== args.channel) return false;
|
||||
if (typeof args.isOfficial === "boolean" && digest.isOfficial !== args.isOfficial) {
|
||||
return false;
|
||||
@@ -2545,6 +2546,7 @@ async function fetchHighlightedPackageDigests(
|
||||
ctx: DbReaderCtx,
|
||||
args: {
|
||||
family?: PackageFamily;
|
||||
families?: PackageFamily[];
|
||||
channel?: PackageChannel;
|
||||
isOfficial?: boolean;
|
||||
category?: string;
|
||||
@@ -2577,6 +2579,7 @@ async function fetchHighlightedPackagePage(
|
||||
ctx: DbReaderCtx,
|
||||
args: {
|
||||
family?: PackageFamily;
|
||||
families?: PackageFamily[];
|
||||
channel?: PackageChannel;
|
||||
isOfficial?: boolean;
|
||||
category?: string;
|
||||
@@ -2591,32 +2594,14 @@ async function fetchHighlightedPackagePage(
|
||||
const items = await Promise.all(
|
||||
digests.map(async (digest) => await toPublicPackageListItem(ctx, digest)),
|
||||
);
|
||||
return items
|
||||
.sort((a, b) => {
|
||||
if (args.officialFirst) {
|
||||
const official = Number(b.isOfficial) - Number(a.isOfficial);
|
||||
if (official !== 0) return official;
|
||||
}
|
||||
if (args.sort === "recommended") {
|
||||
const recommendation = compareRecommendationStats(a.stats, b.stats);
|
||||
if (recommendation !== 0) return recommendation;
|
||||
}
|
||||
if (args.sort === "installs") {
|
||||
const installs = b.stats.installs - a.stats.installs;
|
||||
if (installs !== 0) return installs;
|
||||
}
|
||||
if (args.sort === "downloads") {
|
||||
const downloads = b.stats.downloads - a.stats.downloads;
|
||||
if (downloads !== 0) return downloads;
|
||||
}
|
||||
return (
|
||||
b.updatedAt - a.updatedAt ||
|
||||
b.createdAt - a.createdAt ||
|
||||
a.family.localeCompare(b.family) ||
|
||||
a.name.localeCompare(b.name)
|
||||
);
|
||||
})
|
||||
.slice(0, args.numItems);
|
||||
// fetchHighlightedPackageDigests follows the badge timestamp index newest-first.
|
||||
// Preserve that editorial order instead of re-ranking Featured by popularity.
|
||||
if (!args.officialFirst) {
|
||||
return items.slice(0, args.numItems);
|
||||
}
|
||||
const official = items.filter((item) => item.isOfficial);
|
||||
const community = items.filter((item) => !item.isOfficial);
|
||||
return [...official, ...community].slice(0, args.numItems);
|
||||
}
|
||||
|
||||
async function getPackageByNormalizedName(ctx: DbReaderCtx, normalizedName: string) {
|
||||
@@ -3651,6 +3636,9 @@ export const listPageForViewerInternal = internalQuery({
|
||||
family: v.optional(
|
||||
v.union(v.literal("skill"), v.literal("code-plugin"), v.literal("bundle-plugin")),
|
||||
),
|
||||
families: v.optional(
|
||||
v.array(v.union(v.literal("skill"), v.literal("code-plugin"), v.literal("bundle-plugin"))),
|
||||
),
|
||||
channel: v.optional(
|
||||
v.union(v.literal("official"), v.literal("community"), v.literal("private")),
|
||||
),
|
||||
@@ -3713,6 +3701,7 @@ async function listPackagePageImpl(
|
||||
ctx: DbReaderCtx,
|
||||
args: {
|
||||
family?: PackageFamily;
|
||||
families?: PackageFamily[];
|
||||
channel?: PackageChannel;
|
||||
isOfficial?: boolean;
|
||||
highlightedOnly?: boolean;
|
||||
@@ -3728,6 +3717,9 @@ async function listPackagePageImpl(
|
||||
if (args.channel === "private" && !args.viewerUserId) {
|
||||
return { page: [], isDone: true, continueCursor: "" };
|
||||
}
|
||||
if (args.families?.length && !args.highlightedOnly) {
|
||||
throw new Error("families is only supported for highlighted package pages");
|
||||
}
|
||||
if (args.category && !isPluginCategorySlug(args.category)) {
|
||||
return { page: [], isDone: true, continueCursor: "" };
|
||||
}
|
||||
|
||||
@@ -105,36 +105,36 @@ describe("skills.listPublicPageV4", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("sorts highlighted recommended results by weighted score, then updatedAt", async () => {
|
||||
it("keeps highlighted results in newest-featured order", async () => {
|
||||
const result = await listPublicPageV4Handler(
|
||||
makeHighlightedCtx([
|
||||
makeDigest({
|
||||
id: "updated",
|
||||
slug: "updated-skill",
|
||||
id: "newest",
|
||||
slug: "newest-featured",
|
||||
stars: 2,
|
||||
installsAllTime: 10,
|
||||
downloads: 10,
|
||||
updatedAt: 400,
|
||||
}),
|
||||
makeDigest({
|
||||
id: "downloads",
|
||||
slug: "downloads-skill",
|
||||
id: "older-popular",
|
||||
slug: "older-popular",
|
||||
stars: 2,
|
||||
installsAllTime: 10,
|
||||
downloads: 50,
|
||||
updatedAt: 100,
|
||||
}),
|
||||
makeDigest({
|
||||
id: "installs",
|
||||
slug: "installs-skill",
|
||||
id: "older-installed",
|
||||
slug: "older-installed",
|
||||
stars: 2,
|
||||
installsAllTime: 20,
|
||||
downloads: 0,
|
||||
updatedAt: 100,
|
||||
}),
|
||||
makeDigest({
|
||||
id: "stars",
|
||||
slug: "stars-skill",
|
||||
id: "oldest-starred",
|
||||
slug: "oldest-starred",
|
||||
stars: 3,
|
||||
installsAllTime: 0,
|
||||
downloads: 0,
|
||||
@@ -145,10 +145,10 @@ describe("skills.listPublicPageV4", () => {
|
||||
);
|
||||
|
||||
expect(result.page.map((entry) => entry.skill.slug)).toEqual([
|
||||
"downloads-skill",
|
||||
"installs-skill",
|
||||
"updated-skill",
|
||||
"stars-skill",
|
||||
"newest-featured",
|
||||
"older-popular",
|
||||
"older-installed",
|
||||
"oldest-starred",
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
+2
-58
@@ -106,10 +106,7 @@ import {
|
||||
normalizePublisherHandle,
|
||||
requirePublisherRole,
|
||||
} from "./lib/publishers";
|
||||
import {
|
||||
computeRecommendationScore,
|
||||
RECOMMENDATION_SCORE_VERSION,
|
||||
} from "./lib/recommendationScore";
|
||||
import { RECOMMENDATION_SCORE_VERSION } from "./lib/recommendationScore";
|
||||
import {
|
||||
AUTO_HIDE_REPORT_THRESHOLD,
|
||||
MAX_ACTIVE_REPORTS_PER_USER,
|
||||
@@ -7154,57 +7151,6 @@ function readDigestRankStat(
|
||||
return digest.statsInstallsAllTime ?? digest.stats.installsAllTime ?? 0;
|
||||
}
|
||||
|
||||
function readDigestRecommendationScore(digest: Doc<"skillSearchDigest">): number {
|
||||
return (
|
||||
(digest.recommendedScoreVersion === RECOMMENDATION_SCORE_VERSION
|
||||
? digest.recommendedScore
|
||||
: undefined) ??
|
||||
computeRecommendationScore(
|
||||
{
|
||||
downloads: readDigestRankStat(digest, "downloads"),
|
||||
installs: readDigestRankStat(digest, "installsAllTime"),
|
||||
stars: readDigestRankStat(digest, "stars"),
|
||||
},
|
||||
{
|
||||
createdAt: digest.createdAt,
|
||||
updatedAt: digest.updatedAt,
|
||||
},
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function compareSkillDigestsForPublicSort(
|
||||
a: Doc<"skillSearchDigest">,
|
||||
b: Doc<"skillSearchDigest">,
|
||||
sort: PublicListSort,
|
||||
dir: "asc" | "desc",
|
||||
) {
|
||||
const multiplier = dir === "asc" ? 1 : -1;
|
||||
switch (sort) {
|
||||
case "downloads":
|
||||
return (readDigestRankStat(a, "downloads") - readDigestRankStat(b, "downloads")) * multiplier;
|
||||
case "recommended":
|
||||
return (
|
||||
(readDigestRecommendationScore(a) - readDigestRecommendationScore(b)) * multiplier ||
|
||||
(a.updatedAt - b.updatedAt) * multiplier
|
||||
);
|
||||
case "stars":
|
||||
return (readDigestRankStat(a, "stars") - readDigestRankStat(b, "stars")) * multiplier;
|
||||
case "installs":
|
||||
return (
|
||||
(readDigestRankStat(a, "installsAllTime") - readDigestRankStat(b, "installsAllTime")) *
|
||||
multiplier
|
||||
);
|
||||
case "updated":
|
||||
return (a.updatedAt - b.updatedAt) * multiplier;
|
||||
case "name":
|
||||
return a.displayName.localeCompare(b.displayName) * multiplier;
|
||||
case "newest":
|
||||
default:
|
||||
return (a.createdAt - b.createdAt) * multiplier;
|
||||
}
|
||||
}
|
||||
|
||||
type OfficialFirstSkillCategoryPageOptions = {
|
||||
sort: PublicListSort;
|
||||
dir: "asc" | "desc";
|
||||
@@ -7476,7 +7422,7 @@ async function listOfficialFirstSkillCategoryPage(
|
||||
};
|
||||
}
|
||||
|
||||
/** Fetch highlighted skills via the skillBadges index, then sort in JS. */
|
||||
/** Fetch highlighted skills newest-first via the skillBadges timestamp index. */
|
||||
async function fetchHighlightedPage(
|
||||
ctx: QueryCtx,
|
||||
opts: {
|
||||
@@ -7519,8 +7465,6 @@ async function fetchHighlightedPage(
|
||||
digests.push(digest);
|
||||
}
|
||||
|
||||
digests.sort((a, b) => compareSkillDigestsForPublicSort(a, b, opts.sort, opts.dir));
|
||||
|
||||
const trimmed = digests.slice(0, opts.numItems);
|
||||
|
||||
const items: PublicSkillEntry[] = [];
|
||||
|
||||
+6
@@ -31,6 +31,7 @@ export declare const CatalogFeedPluginEntrySchema: import("arktype/internal/vari
|
||||
version: string;
|
||||
state: "available" | "blocked" | "deprecated" | "disabled" | "recommended";
|
||||
featured?: boolean | undefined;
|
||||
featuredAt?: number | undefined;
|
||||
publisher: {
|
||||
id: string;
|
||||
trust: "community" | "official";
|
||||
@@ -60,6 +61,7 @@ export declare const CatalogFeedSkillEntrySchema: import("arktype/internal/varia
|
||||
version: string;
|
||||
state: "available" | "blocked" | "deprecated" | "disabled" | "recommended";
|
||||
featured?: boolean | undefined;
|
||||
featuredAt?: number | undefined;
|
||||
publisher: {
|
||||
id: string;
|
||||
trust: "community" | "official";
|
||||
@@ -89,6 +91,7 @@ export declare const CatalogFeedEntrySchema: import("arktype/internal/variants/o
|
||||
version: string;
|
||||
state: "available" | "blocked" | "deprecated" | "disabled" | "recommended";
|
||||
featured?: boolean | undefined;
|
||||
featuredAt?: number | undefined;
|
||||
publisher: {
|
||||
id: string;
|
||||
trust: "community" | "official";
|
||||
@@ -116,6 +119,7 @@ export declare const CatalogFeedEntrySchema: import("arktype/internal/variants/o
|
||||
version: string;
|
||||
state: "available" | "blocked" | "deprecated" | "disabled" | "recommended";
|
||||
featured?: boolean | undefined;
|
||||
featuredAt?: number | undefined;
|
||||
publisher: {
|
||||
id: string;
|
||||
trust: "community" | "official";
|
||||
@@ -152,6 +156,7 @@ export declare const CatalogFeedSchema: import("arktype/internal/variants/object
|
||||
version: string;
|
||||
state: "available" | "blocked" | "deprecated" | "disabled" | "recommended";
|
||||
featured?: boolean | undefined;
|
||||
featuredAt?: number | undefined;
|
||||
publisher: {
|
||||
id: string;
|
||||
trust: "community" | "official";
|
||||
@@ -179,6 +184,7 @@ export declare const CatalogFeedSchema: import("arktype/internal/variants/object
|
||||
version: string;
|
||||
state: "available" | "blocked" | "deprecated" | "disabled" | "recommended";
|
||||
featured?: boolean | undefined;
|
||||
featuredAt?: number | undefined;
|
||||
publisher: {
|
||||
id: string;
|
||||
trust: "community" | "official";
|
||||
|
||||
Vendored
+8
@@ -26,6 +26,7 @@ const CatalogFeedEntryBaseSchema = {
|
||||
state: CatalogFeedStateSchema,
|
||||
// Additive v1 metadata: existing hosted-feed consumers ignore unknown entry fields.
|
||||
featured: "boolean?",
|
||||
featuredAt: "number?",
|
||||
publisher: {
|
||||
"+": "reject",
|
||||
id: "string",
|
||||
@@ -81,6 +82,12 @@ export function parseCatalogFeed(value) {
|
||||
if (Date.parse(feed.expiresAt) <= Date.parse(feed.generatedAt)) {
|
||||
throw new Error("Catalog feed expiresAt must be after generatedAt");
|
||||
}
|
||||
for (const entry of feed.entries) {
|
||||
if (entry.featuredAt !== undefined &&
|
||||
(entry.featured !== true || !Number.isSafeInteger(entry.featuredAt) || entry.featuredAt < 0)) {
|
||||
throw new Error("Catalog feed featuredAt requires a featured entry and epoch milliseconds");
|
||||
}
|
||||
}
|
||||
return feed;
|
||||
}
|
||||
export function serializeCatalogFeed(feed) {
|
||||
@@ -96,6 +103,7 @@ export function serializeCatalogFeed(feed) {
|
||||
version: entry.version,
|
||||
state: entry.state,
|
||||
...(entry.featured === undefined ? {} : { featured: entry.featured }),
|
||||
...(entry.featuredAt === undefined ? {} : { featuredAt: entry.featuredAt }),
|
||||
publisher: {
|
||||
id: entry.publisher.id,
|
||||
trust: entry.publisher.trust,
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
@@ -151,6 +151,7 @@ describe("catalog feed schema", () => {
|
||||
entries: makeFeed().entries.map((entry, index) => ({
|
||||
...entry,
|
||||
featured: index === 0,
|
||||
...(index === 0 ? { featuredAt: 1_784_280_000_000 } : {}),
|
||||
})),
|
||||
});
|
||||
|
||||
@@ -158,8 +159,25 @@ describe("catalog feed schema", () => {
|
||||
|
||||
expect(parsed.schemaVersion).toBe(1);
|
||||
expect(parsed.entries.find((entry) => entry.id === "zeta")?.featured).toBe(true);
|
||||
expect(parsed.entries.find((entry) => entry.id === "zeta")?.featuredAt).toBe(1_784_280_000_000);
|
||||
expect(parsed.entries.find((entry) => entry.id === "alpha")?.featured).toBe(false);
|
||||
expect(parseCatalogFeed(makeFeed()).entries[0]).not.toHaveProperty("featured");
|
||||
expect(parseCatalogFeed(makeFeed()).entries[0]).not.toHaveProperty("featuredAt");
|
||||
});
|
||||
|
||||
it("rejects featured timestamps on entries that are not featured", () => {
|
||||
expect(() =>
|
||||
parseCatalogFeed({
|
||||
...makeFeed(),
|
||||
entries: [
|
||||
{
|
||||
...makeFeed().entries[0],
|
||||
featured: false,
|
||||
featuredAt: 1_784_280_000_000,
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toThrow("featuredAt");
|
||||
});
|
||||
|
||||
it("round-trips optional listing metadata without changing schema version 1", () => {
|
||||
|
||||
@@ -37,6 +37,7 @@ const CatalogFeedEntryBaseSchema = {
|
||||
state: CatalogFeedStateSchema,
|
||||
// Additive v1 metadata: existing hosted-feed consumers ignore unknown entry fields.
|
||||
featured: "boolean?",
|
||||
featuredAt: "number?",
|
||||
publisher: {
|
||||
"+": "reject",
|
||||
id: "string",
|
||||
@@ -107,6 +108,14 @@ export function parseCatalogFeed(value: unknown): CatalogFeed {
|
||||
if (Date.parse(feed.expiresAt) <= Date.parse(feed.generatedAt)) {
|
||||
throw new Error("Catalog feed expiresAt must be after generatedAt");
|
||||
}
|
||||
for (const entry of feed.entries) {
|
||||
if (
|
||||
entry.featuredAt !== undefined &&
|
||||
(entry.featured !== true || !Number.isSafeInteger(entry.featuredAt) || entry.featuredAt < 0)
|
||||
) {
|
||||
throw new Error("Catalog feed featuredAt requires a featured entry and epoch milliseconds");
|
||||
}
|
||||
}
|
||||
return feed;
|
||||
}
|
||||
|
||||
@@ -123,6 +132,7 @@ export function serializeCatalogFeed(feed: CatalogFeed): string {
|
||||
version: entry.version,
|
||||
state: entry.state,
|
||||
...(entry.featured === undefined ? {} : { featured: entry.featured }),
|
||||
...(entry.featuredAt === undefined ? {} : { featuredAt: entry.featuredAt }),
|
||||
publisher: {
|
||||
id: entry.publisher.id,
|
||||
trust: entry.publisher.trust,
|
||||
|
||||
Reference in New Issue
Block a user