diff --git a/convex/catalogFeed.test.ts b/convex/catalogFeed.test.ts index 0aa210a5..bd3b5e86 100644 --- a/convex/catalogFeed.test.ts +++ b/convex/catalogFeed.test.ts @@ -135,7 +135,7 @@ function makeFeedSkillEntry(index: number) { function makeCtx( packages: unknown[], records: Record, - 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, }), ]); }); diff --git a/convex/catalogFeed.ts b/convex/catalogFeed.ts index ed602678..4ec72fdb 100644 --- a/convex/catalogFeed.ts +++ b/convex/catalogFeed.ts @@ -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", diff --git a/convex/httpApiV1.handlers.test.ts b/convex/httpApiV1.handlers.test.ts index 0b337d99..ac3710de 100644 --- a/convex/httpApiV1.handlers.test.ts +++ b/convex/httpApiV1.handlers.test.ts @@ -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) => { - 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 () => { diff --git a/convex/httpApiV1/packagesV1.ts b/convex/httpApiV1/packagesV1.ts index 91a51951..140c3a05 100644 --- a/convex/httpApiV1/packagesV1.ts +++ b/convex/httpApiV1/packagesV1.ts @@ -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; diff --git a/convex/packages.public.test.ts b/convex/packages.public.test.ts index 6ffb460b..3bb008ee 100644 --- a/convex/packages.public.test.ts +++ b/convex/packages.public.test.ts @@ -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 () => { diff --git a/convex/packages.ts b/convex/packages.ts index 7b7525a4..e9ae73f7 100644 --- a/convex/packages.ts +++ b/convex/packages.ts @@ -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: "" }; } diff --git a/convex/skills.listPublicPageV4.test.ts b/convex/skills.listPublicPageV4.test.ts index 3207a7e4..c2b12170 100644 --- a/convex/skills.listPublicPageV4.test.ts +++ b/convex/skills.listPublicPageV4.test.ts @@ -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", ]); }); diff --git a/convex/skills.ts b/convex/skills.ts index ccb17acb..ea5ffc81 100644 --- a/convex/skills.ts +++ b/convex/skills.ts @@ -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[] = []; diff --git a/packages/schema/dist/catalogFeed.d.ts b/packages/schema/dist/catalogFeed.d.ts index b18b08aa..eaf26d20 100644 --- a/packages/schema/dist/catalogFeed.d.ts +++ b/packages/schema/dist/catalogFeed.d.ts @@ -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"; diff --git a/packages/schema/dist/catalogFeed.js b/packages/schema/dist/catalogFeed.js index e4c72b04..b32ca672 100644 --- a/packages/schema/dist/catalogFeed.js +++ b/packages/schema/dist/catalogFeed.js @@ -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, diff --git a/packages/schema/dist/catalogFeed.js.map b/packages/schema/dist/catalogFeed.js.map index 629b8f01..3a4b0eef 100644 --- a/packages/schema/dist/catalogFeed.js.map +++ b/packages/schema/dist/catalogFeed.js.map @@ -1 +1 @@ -{"version":3,"file":"catalogFeed.js","sourceRoot":"","sources":["../src/catalogFeed.ts"],"names":[],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"catalogFeed.js","sourceRoot":"","sources":["../src/catalogFeed.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/packages/schema/src/catalogFeed.test.ts b/packages/schema/src/catalogFeed.test.ts index 7c0eb590..aa464a27 100644 --- a/packages/schema/src/catalogFeed.test.ts +++ b/packages/schema/src/catalogFeed.test.ts @@ -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", () => { diff --git a/packages/schema/src/catalogFeed.ts b/packages/schema/src/catalogFeed.ts index 2c563cca..8228e664 100644 --- a/packages/schema/src/catalogFeed.ts +++ b/packages/schema/src/catalogFeed.ts @@ -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,