From 379c1871f4a1f579ea31d62b7869269a28cd3876 Mon Sep 17 00:00:00 2001 From: Jesse Merhi <79823012+jesse-merhi@users.noreply.github.com> Date: Mon, 22 Jun 2026 11:59:30 +1000 Subject: [PATCH] Add skill and plugin download activity graphs Restore downloads as the public activity metric and add deferred 30-day download graphs for skills and plugins. --- convex/_generated/api.d.ts | 2 + convex/crons.test.ts | 19 + convex/crons.ts | 14 +- convex/devSeed.localFixtures.test.ts | 307 +++++++ convex/devSeed.ts | 295 ++++++- convex/httpApiV1.ambiguousSkillSlug.test.ts | 1 + convex/httpApiV1.handlers.test.ts | 438 +++++++++- convex/httpApiV1/packagesV1.ts | 173 +++- convex/httpApiV1/skillsV1.ts | 34 +- convex/lib/downloadTrend.test.ts | 40 + convex/lib/downloadTrend.ts | 62 ++ convex/lib/retentionPolicy.test.ts | 15 + convex/lib/retentionPolicy.ts | 5 +- convex/maintenance.test.ts | 1 + convex/packages.public.test.ts | 34 +- convex/packages.stats.test.ts | 750 +++++++++++++++++- convex/packages.ts | 419 +++++++++- convex/publishers.test.ts | 292 ++++++- convex/publishers.ts | 107 ++- convex/schema.ts | 88 ++ convex/securityScan.test.ts | 1 + convex/skills.packageCatalog.test.ts | 58 +- convex/skills.public.test.ts | 85 ++ convex/skills.publicListCursor.test.ts | 58 +- convex/skills.resolve.test.ts | 2 +- convex/skills.ts | 78 +- convex/stars.test.ts | 1 + convex/users.test.ts | 53 ++ convex/users.ts | 3 +- docs/api.md | 6 +- docs/clawhub.md | 2 +- docs/cli.md | 2 +- docs/http-api.md | 8 +- docs/security-audits.md | 2 +- packages/clawhub/src/cli.ts | 6 +- .../clawhub/src/cli/commands/skills.test.ts | 21 +- packages/clawhub/src/cli/commands/skills.ts | 15 +- public/api/v1/openapi.json | 57 ++ specs/download-metering.md | 13 + src/__tests__/openapi-contract.test.ts | 32 +- src/__tests__/package-detail-route.test.tsx | 140 +++- src/__tests__/packages-route.test.tsx | 181 +++-- src/__tests__/skill-detail-page.test.tsx | 213 ++++- src/__tests__/skills-index.test.tsx | 26 +- src/__tests__/user-profile-route.test.tsx | 12 +- src/components/ActivityMetricLabel.tsx | 29 + src/components/HomeListingSection.tsx | 18 +- src/components/MetricTrendCard.tsx | 155 ++++ src/components/PluginListItem.tsx | 8 +- src/components/PublishedItemCard.test.tsx | 16 +- src/components/PublisherListItem.test.tsx | 16 +- src/components/PublisherListItem.tsx | 16 +- src/components/SkillDetailPage.tsx | 16 + src/components/SkillHeader.test.tsx | 80 +- src/components/SkillHeader.tsx | 39 +- src/components/SkillListItem.tsx | 5 +- src/components/SkillStats.tsx | 6 +- src/components/UserBadge.test.tsx | 6 +- src/components/UserBadge.tsx | 12 +- src/lib/activityTrend.ts | 49 ++ src/lib/packageApi.test.ts | 8 +- src/lib/packageApi.ts | 2 +- src/lib/publicUser.ts | 4 +- src/lib/runtimeEnv.test.ts | 12 + src/lib/runtimeEnv.ts | 3 + src/lib/skillPage.test.ts | 1 + src/lib/skillPage.ts | 13 +- src/lib/useDeferredActivityTrend.ts | 130 +++ src/routes/-dashboard.test.tsx | 8 +- src/routes/audits.tsx | 15 +- src/routes/dashboard.tsx | 4 +- src/routes/plugins/$name.tsx | 38 +- src/routes/plugins/index.tsx | 85 +- src/routes/skills/-params.test.ts | 8 +- src/routes/skills/-params.ts | 4 +- src/routes/skills/-useSkillsBrowseModel.ts | 7 +- src/routes/skills/index.tsx | 2 +- src/routes/user/$handle.tsx | 26 +- src/styles.css | 173 +++- 79 files changed, 4690 insertions(+), 495 deletions(-) create mode 100644 convex/lib/downloadTrend.test.ts create mode 100644 convex/lib/downloadTrend.ts create mode 100644 src/components/ActivityMetricLabel.tsx create mode 100644 src/components/MetricTrendCard.tsx create mode 100644 src/lib/activityTrend.ts create mode 100644 src/lib/useDeferredActivityTrend.ts diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index 31cdf1eb..ae86b105 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -55,6 +55,7 @@ import type * as lib_clawpack from "../lib/clawpack.js"; import type * as lib_contentTypes from "../lib/contentTypes.js"; import type * as lib_devAuth from "../lib/devAuth.js"; import type * as lib_devSeed from "../lib/devSeed.js"; +import type * as lib_downloadTrend from "../lib/downloadTrend.js"; import type * as lib_emailRendering from "../lib/emailRendering.js"; import type * as lib_emails from "../lib/emails.js"; import type * as lib_embeddingVisibility from "../lib/embeddingVisibility.js"; @@ -202,6 +203,7 @@ declare const fullApi: ApiFromModules<{ "lib/contentTypes": typeof lib_contentTypes; "lib/devAuth": typeof lib_devAuth; "lib/devSeed": typeof lib_devSeed; + "lib/downloadTrend": typeof lib_downloadTrend; "lib/emailRendering": typeof lib_emailRendering; "lib/emails": typeof lib_emails; "lib/embeddingVisibility": typeof lib_embeddingVisibility; diff --git a/convex/crons.test.ts b/convex/crons.test.ts index fd9b3035..24fe79cb 100644 --- a/convex/crons.test.ts +++ b/convex/crons.test.ts @@ -7,6 +7,7 @@ const mocks = vi.hoisted(() => { const installTelemetryDedupePruneRef = Symbol("install-telemetry-dedupe-prune"); const rateLimitCountersPruneRef = Symbol("rate-limit-counters-prune"); const skillStatEventPruneRef = Symbol("skill-stat-event-prune"); + const packageStatEventPruneRef = Symbol("package-stat-event-prune"); const authSessionsPruneRef = Symbol("auth-sessions-prune"); const authRefreshTokensPruneRef = Symbol("auth-refresh-tokens-prune"); return { @@ -15,6 +16,7 @@ const mocks = vi.hoisted(() => { installTelemetryDedupePruneRef, rateLimitCountersPruneRef, skillStatEventPruneRef, + packageStatEventPruneRef, authSessionsPruneRef, authRefreshTokensPruneRef, }; @@ -41,6 +43,7 @@ vi.mock("./_generated/api", () => ({ }, packages: { processPackageStatEventsInternal: Symbol("package-stat-events"), + pruneProcessedPackageStatEventsInternal: mocks.packageStatEventPruneRef, backfillPackageReleaseScansInternal: Symbol("package-scan-backfill"), }, publisherAbuse: { @@ -164,4 +167,20 @@ describe("crons", () => { }, ); }); + + it("prunes processed package stat events daily with a seven-day retention window", async () => { + await import("./crons"); + + expect(mocks.interval).toHaveBeenCalledWith( + "package-stat-events-prune", + { hours: 24 }, + mocks.packageStatEventPruneRef, + { + retentionDays: 7, + batchSize: 1000, + maxBatches: 20, + confirmationToken: "PRUNE_PROCESSED_PACKAGE_STAT_EVENTS", + }, + ); + }); }); diff --git a/convex/crons.ts b/convex/crons.ts index 950f1c2d..b71126f3 100644 --- a/convex/crons.ts +++ b/convex/crons.ts @@ -39,7 +39,7 @@ if (process.env.CLAWHUB_DISABLE_CRONS !== "1") { "package-stat-events", { minutes: 15 }, internal.packages.processPackageStatEventsInternal, - { batchSize: 500 }, + { batchSize: 100 }, ); // Syncs accumulated stat deltas to skill documents every 6 hours. @@ -64,6 +64,18 @@ if (process.env.CLAWHUB_DISABLE_CRONS !== "1") { }, ); + crons.interval( + "package-stat-events-prune", + { hours: 24 }, + internal.packages.pruneProcessedPackageStatEventsInternal, + { + retentionDays: 7, + batchSize: 1000, + maxBatches: 20, + confirmationToken: "PRUNE_PROCESSED_PACKAGE_STAT_EVENTS", + }, + ); + crons.interval( "global-stats-update", { hours: 24 }, diff --git a/convex/devSeed.localFixtures.test.ts b/convex/devSeed.localFixtures.test.ts index 8cd1b0ab..2a5ebfcd 100644 --- a/convex/devSeed.localFixtures.test.ts +++ b/convex/devSeed.localFixtures.test.ts @@ -1,12 +1,14 @@ import { describe, expect, it } from "vitest"; import type { Id } from "./_generated/dataModel"; import { + backfillExistingPublicCorpusBatchRows, currentUserSeedPackageName, currentUserSeedSkillSlug, seedFeaturedPluginPackagesMutation, seedGitHubBackedSkillSourceMutation, seedLocalFixtures, seedLocalModerationFixturesHandler, + seedPublicCorpusBatch, seedPublicCorpusBatchMutation, seedSkillMutation, } from "./devSeed"; @@ -27,9 +29,15 @@ const seedGitHubBackedSkillSourceHandler = ( const seedLocalFixturesHandler = ( seedLocalFixtures as unknown as WrappedHandler<{ reset?: boolean }> )._handler; +const seedPublicCorpusBatchActionHandler = ( + seedPublicCorpusBatch as unknown as WrappedHandler> +)._handler; const seedPublicCorpusBatchHandler = ( seedPublicCorpusBatchMutation as unknown as WrappedHandler> )._handler; +const backfillExistingPublicCorpusBatchRowsHandler = ( + backfillExistingPublicCorpusBatchRows as unknown as WrappedHandler> +)._handler; function chainEq(constraints: Record) { return { @@ -261,6 +269,293 @@ describe("devSeed local fixtures", () => { }), ); expect(tables.skillEmbeddings?.[0]).not.toHaveProperty("ownerPublisherId"); + expect( + (tables.skillDailyStats ?? []).reduce((sum, row) => sum + Number(row.downloads), 0), + ).toBe(tables.skills?.[0]?.statsDownloads); + expect((tables.skillDailyStats ?? []).reduce((sum, row) => sum + Number(row.installs), 0)).toBe( + tables.skills?.[0]?.statsInstallsAllTime, + ); + }); + + it("backfills daily activity for existing public corpus skills", async () => { + const { db, tables } = createDb(); + const userId = (await db.insert("users", { + handle: "corpus-owner", + displayName: "Corpus Owner", + role: "user", + createdAt: 1, + updatedAt: 1, + })) as Id<"users">; + const publisherId = (await db.insert("publishers", { + kind: "user", + handle: "corpus-owner", + displayName: "Corpus Owner", + linkedUserId: userId, + createdAt: 1, + updatedAt: 1, + })) as Id<"publishers">; + + await db.insert("skills", { + slug: "corpus-demo", + displayName: "Corpus Demo", + ownerUserId: userId, + ownerPublisherId: publisherId, + batch: "public-corpus-v1", + tags: {}, + badges: {}, + statsDownloads: 143, + statsStars: 7, + statsInstallsCurrent: 18, + statsInstallsAllTime: 23, + stats: { + downloads: 143, + stars: 7, + installsCurrent: 18, + installsAllTime: 23, + versions: 1, + comments: 0, + }, + createdAt: 1, + updatedAt: 1, + }); + + const result = await seedPublicCorpusBatchHandler( + createMutationCtx(db) as never, + { + rows: [ + { + kind: "skill", + slug: "corpus-demo", + displayName: "Corpus Demo", + version: "0.1.0", + skillMd: "---\ndescription: Corpus demo\n---\n# Corpus demo", + storageId: "storage:corpus-demo", + embedding: [0, 1, 2], + dummyOwner: { + handle: "corpus-owner", + displayName: "Corpus Owner", + image: "https://example.invalid/avatar.png", + }, + }, + ], + } as never, + ); + + const rows = tables.skillDailyStats ?? []; + expect(result).toEqual({ ok: true, seeded: [], skipped: ["skill:corpus-demo"] }); + expect(rows).toHaveLength(30); + expect(rows.reduce((sum, row) => sum + Number(row.downloads), 0)).toBe(143); + expect(rows.reduce((sum, row) => sum + Number(row.installs), 0)).toBe(23); + }); + + it("pre-skips existing public corpus rows before storage and embedding prep", async () => { + const { db, tables } = createDb(); + const userId = (await db.insert("users", { + handle: "corpus-owner", + displayName: "Corpus Owner", + role: "user", + createdAt: 1, + updatedAt: 1, + })) as Id<"users">; + const publisherId = (await db.insert("publishers", { + kind: "user", + handle: "corpus-owner", + displayName: "Corpus Owner", + linkedUserId: userId, + createdAt: 1, + updatedAt: 1, + })) as Id<"publishers">; + + await db.insert("skills", { + slug: "corpus-demo", + displayName: "Corpus Demo", + ownerUserId: userId, + ownerPublisherId: publisherId, + batch: "public-corpus-v1", + tags: {}, + badges: {}, + statsDownloads: 143, + statsStars: 7, + statsInstallsCurrent: 18, + statsInstallsAllTime: 23, + stats: { + downloads: 143, + stars: 7, + installsCurrent: 18, + installsAllTime: 23, + versions: 1, + comments: 0, + }, + createdAt: 1, + updatedAt: 1, + }); + await db.insert("packages", { + name: "demo-plugin", + normalizedName: "demo-plugin", + displayName: "Demo Plugin", + ownerUserId: userId, + ownerPublisherId: publisherId, + stats: { downloads: 57, installs: 13, stars: 2, versions: 1 }, + createdAt: 1, + updatedAt: 1, + }); + + const mutationCtx = createMutationCtx(db); + const storageStore = async () => { + throw new Error("existing public corpus rows should not store files"); + }; + const result = await seedPublicCorpusBatchActionHandler( + { + storage: { store: storageStore }, + runMutation: async (_ref: unknown, args: Record) => + backfillExistingPublicCorpusBatchRowsHandler(mutationCtx as never, args), + } as never, + { + rows: [ + { + kind: "skill", + slug: "corpus-demo", + displayName: "Corpus Demo", + version: "0.1.0", + skillMd: "---\ndescription: Corpus demo\n---\n# Corpus demo", + dummyOwner: { + handle: "corpus-owner", + displayName: "Corpus Owner", + image: "https://example.invalid/avatar.png", + }, + }, + { + kind: "plugin", + name: "demo-plugin", + displayName: "Demo Plugin", + version: "0.1.0", + readme: "# Demo plugin", + dummyOwner: { + handle: "corpus-owner", + displayName: "Corpus Owner", + image: "https://example.invalid/avatar.png", + }, + }, + ], + } as never, + ); + + expect(result).toEqual({ + ok: true, + seeded: [], + skipped: ["skill:corpus-demo", "plugin:demo-plugin"], + }); + expect(tables.skillDailyStats).toHaveLength(30); + expect((tables.packageDailyStats ?? []).length).toBeGreaterThan(0); + expect( + (tables.packageDailyStats ?? []).reduce((sum, row) => sum + Number(row.downloads), 0), + ).toBe(57); + expect( + (tables.packageDailyStats ?? []).reduce((sum, row) => sum + Number(row.installs), 0), + ).toBe(13); + }); + + it("seeds daily activity for new public corpus packages", async () => { + const { db, tables } = createDb(); + + await seedPublicCorpusBatchHandler( + createMutationCtx(db) as never, + { + rows: [ + { + kind: "plugin", + name: "demo-plugin", + displayName: "Demo Plugin", + version: "0.1.0", + readme: "# Demo plugin", + storageId: "storage:demo-plugin", + dummyOwner: { + handle: "corpus-owner", + displayName: "Corpus Owner", + image: "https://example.invalid/avatar.png", + }, + }, + ], + } as never, + ); + + const pkg = tables.packages?.find((candidate) => candidate.name === "demo-plugin"); + const stats = pkg?.stats; + const downloads = + stats && + typeof stats === "object" && + "downloads" in stats && + typeof stats.downloads === "number" + ? stats.downloads + : null; + const installs = + stats && + typeof stats === "object" && + "installs" in stats && + typeof stats.installs === "number" + ? stats.installs + : null; + expect(pkg).toBeTruthy(); + expect(downloads).not.toBeNull(); + expect(installs).not.toBeNull(); + expect((tables.packageDailyStats ?? []).length).toBeGreaterThan(0); + expect( + (tables.packageDailyStats ?? []).reduce((sum, row) => sum + Number(row.downloads), 0), + ).toBe(downloads); + expect( + (tables.packageDailyStats ?? []).reduce((sum, row) => sum + Number(row.installs), 0), + ).toBe(installs); + }); + + it("removes public corpus daily activity rows during reset", async () => { + const { db, tables } = createDb(); + const rows = [ + { + kind: "skill", + slug: "corpus-demo", + displayName: "Corpus Demo", + version: "0.1.0", + skillMd: "---\ndescription: Corpus demo\n---\n# Corpus demo", + storageId: "storage:corpus-demo", + embedding: [0, 1, 2], + dummyOwner: { + handle: "corpus-owner", + displayName: "Corpus Owner", + image: "https://example.invalid/avatar.png", + }, + }, + { + kind: "plugin", + name: "demo-plugin", + displayName: "Demo Plugin", + version: "0.1.0", + readme: "# Demo plugin", + storageId: "storage:demo-plugin", + dummyOwner: { + handle: "corpus-owner", + displayName: "Corpus Owner", + image: "https://example.invalid/avatar.png", + }, + }, + ]; + + await seedPublicCorpusBatchHandler(createMutationCtx(db) as never, { rows } as never); + const firstSkillId = tables.skills?.[0]?._id; + const firstPackageId = tables.packages?.[0]?._id; + const firstSkillDailyRows = tables.skillDailyStats?.length ?? 0; + const firstPackageDailyRows = tables.packageDailyStats?.length ?? 0; + + await seedPublicCorpusBatchHandler( + createMutationCtx(db) as never, + { reset: true, resetOwnerHandles: ["corpus-owner"], rows } as never, + ); + + expect(firstSkillDailyRows).toBeGreaterThan(0); + expect(firstPackageDailyRows).toBeGreaterThan(0); + expect(tables.skillDailyStats).toHaveLength(firstSkillDailyRows); + expect(tables.packageDailyStats).toHaveLength(firstPackageDailyRows); + expect(tables.skillDailyStats?.some((row) => row.skillId === firstSkillId)).toBe(false); + expect(tables.packageDailyStats?.some((row) => row.packageId === firstPackageId)).toBe(false); }); it("seeds a GitHub-backed source and skills without creating mirrored versions", async () => { @@ -520,6 +815,18 @@ describe("devSeed local fixtures", () => { }), ]), ); + const scannedPackageId = tables.packages?.find((pkg) => pkg.name === scannedPluginName)?._id; + const scannedPackageDailyStats = (tables.packageDailyStats ?? []).filter( + (row) => row.packageId === scannedPackageId, + ); + expect(tables.packageDailyStats).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + packageId: scannedPackageId, + }), + ]), + ); + expect(scannedPackageDailyStats.reduce((sum, row) => sum + Number(row.downloads), 0)).toBe(7); expect(tables.packageInspectorWarnings).toEqual( expect.arrayContaining([ expect.objectContaining({ diff --git a/convex/devSeed.ts b/convex/devSeed.ts index b40e8c8b..5d0e53cb 100644 --- a/convex/devSeed.ts +++ b/convex/devSeed.ts @@ -4,8 +4,10 @@ import type { Id } from "./_generated/dataModel"; import type { ActionCtx, MutationCtx } from "./_generated/server"; import { internalMutation as rawInternalMutation } from "./_generated/server"; import { internalAction, internalMutation } from "./functions"; +import { ACTIVITY_TREND_DAYS } from "./lib/downloadTrend"; import { EMBEDDING_DIMENSIONS, generateEmbedding } from "./lib/embeddings"; import { deleteGitHubSkillScansForSkill } from "./lib/githubSkillScans"; +import { toDayKey } from "./lib/leaderboards"; import { normalizePackageName } from "./lib/packageRegistry"; import { ensurePersonalPublisherForUser } from "./lib/publishers"; import { @@ -13,6 +15,7 @@ import { RECOMMENDATION_SCORE_VERSION, } from "./lib/recommendationScore"; import { buildEmbeddingText, parseClawdisMetadata, parseFrontmatter } from "./lib/skills"; +import { readCanonicalStat } from "./lib/skillStats"; import { generateToken, hashToken } from "./lib/tokens"; type SeedSkillSpec = { @@ -35,6 +38,17 @@ type SeedActionResult = { }; type SeedMutationResult = Record; +type PublicCorpusExistingRowsResult = { + ok: true; + skipped: string[]; + missingKeys: string[]; +}; + +type PublicCorpusSeedBatchResult = { + ok: boolean; + seeded: string[]; + skipped: string[]; +}; function seededPackageRecommendationScore(stats: { downloads: number; @@ -810,6 +824,62 @@ export const seedLocalFixtures: ReturnType = internalActi handler: seedLocalFixturesHandler, }); +export const backfillExistingPublicCorpusBatchRows = internalMutation({ + args: { + rows: v.array(publicCorpusSeedRowValidator), + }, + handler: async (ctx, args): Promise => { + const now = Date.now(); + const skipped: string[] = []; + const missingKeys: string[] = []; + + for (const row of args.rows) { + if (row.kind === "skill") { + const existing = await ctx.db + .query("skills") + .withIndex("by_slug", (q) => q.eq("slug", row.slug)) + .unique(); + if (!existing) { + missingKeys.push(publicCorpusSeedRowKey(row)); + continue; + } + + if (existing.batch === PUBLIC_CORPUS_BATCH) { + await ensurePublicCorpusSkillDailyStats(ctx, { + skillId: existing._id, + key: row.slug, + downloads: readCanonicalStat(existing, "downloads"), + installs: readCanonicalStat(existing, "installsAllTime"), + now, + }); + } + skipped.push(`skill:${row.slug}`); + continue; + } + + const normalizedName = normalizePackageName(row.name); + const existing = await ctx.db + .query("packages") + .withIndex("by_name", (q) => q.eq("normalizedName", normalizedName)) + .unique(); + if (!existing) { + missingKeys.push(publicCorpusSeedRowKey(row)); + continue; + } + await ensurePublicCorpusPackageDailyStats(ctx, { + packageId: existing._id, + key: row.name, + downloads: existing.stats?.downloads ?? 0, + installs: existing.stats?.installs ?? 0, + now, + }); + skipped.push(`plugin:${row.name}`); + } + + return { ok: true, skipped, missingKeys }; + }, +}); + export const seedPublicCorpusBatch: ReturnType = internalAction({ args: { reset: v.optional(v.boolean()), @@ -817,8 +887,21 @@ export const seedPublicCorpusBatch: ReturnType = internal rows: v.array(publicCorpusSeedRowValidator), }, handler: async (ctx, args) => { + const existingResult: PublicCorpusExistingRowsResult | null = args.reset + ? null + : await ctx.runMutation(internal.devSeed.backfillExistingPublicCorpusBatchRows, { + rows: args.rows, + }); + const missingKeys = new Set(existingResult?.missingKeys ?? []); + const rowsToPrepare = args.reset + ? args.rows + : args.rows.filter((row) => missingKeys.has(publicCorpusSeedRowKey(row))); + if (!args.reset && rowsToPrepare.length === 0) { + return { ok: true, seeded: [], skipped: existingResult?.skipped ?? [] }; + } + const preparedRows = await Promise.all( - args.rows.map(async (row) => { + rowsToPrepare.map(async (row) => { if (row.kind === "skill") { const storageId = await ctx.storage.store( new Blob([row.skillMd], { type: "text/markdown" }), @@ -839,14 +922,29 @@ export const seedPublicCorpusBatch: ReturnType = internal }), ); - return await ctx.runMutation(internal.devSeed.seedPublicCorpusBatchMutation, { - reset: args.reset, - resetOwnerHandles: args.resetOwnerHandles, - rows: preparedRows, - }); + const seedResult: PublicCorpusSeedBatchResult = await ctx.runMutation( + internal.devSeed.seedPublicCorpusBatchMutation, + { + reset: args.reset, + resetOwnerHandles: args.resetOwnerHandles, + rows: preparedRows, + }, + ); + + return { + ok: true, + seeded: seedResult.seeded, + skipped: [...(existingResult?.skipped ?? []), ...seedResult.skipped], + }; }, }); +function publicCorpusSeedRowKey( + row: { kind: "skill"; slug: string } | { kind: "plugin"; name: string }, +) { + return row.kind === "skill" ? `skill:${row.slug}` : `plugin:${row.name}`; +} + export const seedPublicCorpusBatchMutation = internalMutation({ args: { reset: v.optional(v.boolean()), @@ -868,6 +966,15 @@ export const seedPublicCorpusBatchMutation = internalMutation({ .withIndex("by_slug", (q) => q.eq("slug", row.slug)) .unique(); if (existing) { + if (existing.batch === PUBLIC_CORPUS_BATCH) { + await ensurePublicCorpusSkillDailyStats(ctx, { + skillId: existing._id, + key: row.slug, + downloads: readCanonicalStat(existing, "downloads"), + installs: readCanonicalStat(existing, "installsAllTime"), + now, + }); + } skipped.push(`skill:${row.slug}`); continue; } @@ -961,6 +1068,13 @@ export const seedPublicCorpusBatchMutation = internalMutation({ }, updatedAt: now, }); + await ensurePublicCorpusSkillDailyStats(ctx, { + skillId, + key: row.slug, + downloads: stats.downloads, + installs: stats.installsAllTime, + now, + }); seeded.push(`skill:${row.slug}`); } else { const normalizedName = normalizePackageName(row.name); @@ -969,6 +1083,13 @@ export const seedPublicCorpusBatchMutation = internalMutation({ .withIndex("by_name", (q) => q.eq("normalizedName", normalizedName)) .unique(); if (existing) { + await ensurePublicCorpusPackageDailyStats(ctx, { + packageId: existing._id, + key: row.name, + downloads: existing.stats?.downloads ?? 0, + installs: existing.stats?.installs ?? 0, + now, + }); skipped.push(`plugin:${row.name}`); continue; } @@ -1050,6 +1171,13 @@ export const seedPublicCorpusBatchMutation = internalMutation({ stats: { ...stats, versions: 1 }, updatedAt: now, }); + await ensurePublicCorpusPackageDailyStats(ctx, { + packageId, + key: row.name, + downloads: stats.downloads, + installs: stats.installs, + now, + }); seeded.push(`plugin:${row.name}`); } } @@ -1084,6 +1212,122 @@ function publicCorpusSkillStats(slug: string) { }; } +type PublicCorpusDailyStatTotals = { + downloads: number; + installs: number; +}; + +type PublicCorpusDailyStatRow = { + day: number; + downloads: number; + installs: number; +}; + +async function ensurePublicCorpusSkillDailyStats( + ctx: Pick, + params: { + skillId: Id<"skills">; + key: string; + downloads: number; + installs: number; + now: number; + }, +) { + const rows = publicCorpusDailyStats(params.key, params, params.now); + + for (const row of rows) { + const existing = await ctx.db + .query("skillDailyStats") + .withIndex("by_skill_day", (q) => q.eq("skillId", params.skillId).eq("day", row.day)) + .unique(); + if (existing) continue; + + await ctx.db.insert("skillDailyStats", { + skillId: params.skillId, + day: row.day, + downloads: row.downloads, + installs: row.installs, + updatedAt: params.now, + }); + } +} + +async function ensurePublicCorpusPackageDailyStats( + ctx: Pick, + params: { + packageId: Id<"packages">; + key: string; + downloads: number; + installs: number; + now: number; + }, +) { + const rows = publicCorpusDailyStats(params.key, params, params.now); + + for (const row of rows) { + const existing = await ctx.db + .query("packageDailyStats") + .withIndex("by_package_day", (q) => q.eq("packageId", params.packageId).eq("day", row.day)) + .unique(); + if (existing) continue; + + await ctx.db.insert("packageDailyStats", { + packageId: params.packageId, + day: row.day, + downloads: row.downloads, + installs: row.installs, + updatedAt: params.now, + }); + } +} + +function publicCorpusDailyStats( + key: string, + totals: PublicCorpusDailyStatTotals, + now: number, +): PublicCorpusDailyStatRow[] { + const endDay = toDayKey(now); + const startDay = endDay - (ACTIVITY_TREND_DAYS - 1); + const downloads = distributePublicCorpusDailyTotal( + totals.downloads, + publicCorpusStableNumber(`${key}:downloads`), + ); + const installs = distributePublicCorpusDailyTotal( + totals.installs, + publicCorpusStableNumber(`${key}:installs`), + ); + + return Array.from({ length: ACTIVITY_TREND_DAYS }, (_, index) => ({ + day: startDay + index, + downloads: downloads[index] ?? 0, + installs: installs[index] ?? 0, + })).filter((row) => row.downloads > 0 || row.installs > 0); +} + +function distributePublicCorpusDailyTotal(total: number, seed: number) { + const normalizedTotal = Math.max(0, Math.trunc(total)); + const values = Array.from({ length: ACTIVITY_TREND_DAYS }, () => 0); + if (normalizedTotal === 0) return values; + + const weights = Array.from( + { length: ACTIVITY_TREND_DAYS }, + (_, index) => 1 + ((seed + index * 17) % 5) + Math.floor(index / 10), + ); + const totalWeight = weights.reduce((sum, weight) => sum + weight, 0); + for (const [index, weight] of weights.entries()) { + values[index] = Math.floor((normalizedTotal * weight) / totalWeight); + } + + let remainder = normalizedTotal - values.reduce((sum, value) => sum + value, 0); + for (let offset = 0; remainder > 0; offset += 1) { + const index = (seed + offset * 7) % ACTIVITY_TREND_DAYS; + values[index] = (values[index] ?? 0) + 1; + remainder -= 1; + } + + return values; +} + function publicCorpusPackageStats(name: string) { const score = publicCorpusStableNumber(name); return { @@ -1325,6 +1569,7 @@ async function deleteSkillAndVersions(ctx: MutationCtx, skillId: Id<"skills">) { await deleteGitHubSkillScansForSkill(ctx, skillId); await deleteSkillEmbeddingsForSkill(ctx, skillId); await deleteSkillBadgesForSkill(ctx, skillId); + await deleteSkillDailyStatsForSkill(ctx, skillId); await ctx.db.delete(skillId); } @@ -1334,6 +1579,7 @@ async function deletePackageAndReleases(ctx: MutationCtx, packageId: Id<"package .withIndex("by_package", (q) => q.eq("packageId", packageId)) .collect(); await deletePackageBadgesForPackage(ctx, packageId); + await deletePackageDailyStatsForPackage(ctx, packageId); await ctx.db.delete(packageId); for (const release of releases) await ctx.db.delete(release._id); } @@ -1378,6 +1624,22 @@ async function deletePackageBadgesForPackage(ctx: MutationCtx, packageId: Id<"pa for (const badge of badges) await ctx.db.delete(badge._id); } +async function deleteSkillDailyStatsForSkill(ctx: MutationCtx, skillId: Id<"skills">) { + const rows = await ctx.db + .query("skillDailyStats") + .withIndex("by_skill_day", (q) => q.eq("skillId", skillId)) + .collect(); + for (const row of rows) await ctx.db.delete(row._id); +} + +async function deletePackageDailyStatsForPackage(ctx: MutationCtx, packageId: Id<"packages">) { + const rows = await ctx.db + .query("packageDailyStats") + .withIndex("by_package_day", (q) => q.eq("packageId", packageId)) + .collect(); + for (const row of rows) await ctx.db.delete(row._id); +} + async function deleteSeedSkillFixture(ctx: MutationCtx, slug = FLAGGED_SKILL_SLUG) { const existing = await findSeedSkillFixture(ctx, slug); if (!existing) return; @@ -1995,6 +2257,13 @@ export async function seedLocalModerationFixturesHandler( if (pkg.ownerUserId !== userId || pkg.ownerPublisherId !== publisherId) { await ctx.db.patch(pkg._id, ownerPatch); } + await ensurePublicCorpusPackageDailyStats(ctx, { + packageId: pkg._id, + key: pkg.name, + downloads: pkg.stats?.downloads ?? 0, + installs: pkg.stats?.installs ?? 0, + now, + }); } if (existingSkill.latestVersionId) { const latestVersion = await ctx.db.get(existingSkill.latestVersionId); @@ -2392,6 +2661,13 @@ export async function seedLocalModerationFixturesHandler( ...seededPackageRecommendationPatch({ downloads: 2, installs: 0, stars: 0 }), updatedAt: now, }); + await ensurePublicCorpusPackageDailyStats(ctx, { + packageId, + key: flaggedPluginName, + downloads: 2, + installs: 0, + now, + }); const scannedPackageId = await ctx.db.insert("packages", { name: scannedPluginName, normalizedName: normalizePackageName(scannedPluginName), @@ -2489,6 +2765,13 @@ export async function seedLocalModerationFixturesHandler( ...seededPackageRecommendationPatch({ downloads: 7, installs: 1, stars: 1 }), updatedAt: now, }); + await ensurePublicCorpusPackageDailyStats(ctx, { + packageId: scannedPackageId, + key: scannedPluginName, + downloads: 7, + installs: 1, + now, + }); await ctx.db.insert("packageInspectorWarnings", { packageId: scannedPackageId, releaseId: scannedPackageReleaseId, diff --git a/convex/httpApiV1.ambiguousSkillSlug.test.ts b/convex/httpApiV1.ambiguousSkillSlug.test.ts index 989c8bc1..25c64e15 100644 --- a/convex/httpApiV1.ambiguousSkillSlug.test.ts +++ b/convex/httpApiV1.ambiguousSkillSlug.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it, vi } from "vitest"; vi.mock("@convex-dev/auth/server", () => ({ getAuthUserId: vi.fn(), + authTables: {}, })); vi.mock("./lib/apiTokenAuth", () => ({ diff --git a/convex/httpApiV1.handlers.test.ts b/convex/httpApiV1.handlers.test.ts index 424175d6..4bb1ac49 100644 --- a/convex/httpApiV1.handlers.test.ts +++ b/convex/httpApiV1.handlers.test.ts @@ -7,6 +7,7 @@ import { MAX_PUBLISH_FILE_BYTES } from "./lib/publishLimits"; vi.mock("@convex-dev/auth/server", () => ({ getAuthUserId: vi.fn(), + authTables: {}, })); vi.mock("./lib/apiTokenAuth", () => ({ @@ -2087,9 +2088,9 @@ describe("httpApiV1 handlers", () => { ["created-at", "newest"], ["newest", "newest"], ["rating", "stars"], - ["downloads", "installs"], - ["installs", "installs"], - ["installs-all-time", "installs"], + ["downloads", "downloads"], + ["installs", "downloads"], + ["installs-all-time", "downloads"], ["trending", null], ]; @@ -8360,19 +8361,19 @@ describe("httpApiV1 handlers", () => { ); }); - it("packages list install sort merges package and skill rows by installs", async () => { - const pluginPackage = makeCatalogItem("plugin-installed", { + it("packages list downloads sort merges package and skill rows by downloads", async () => { + const pluginPackage = makeCatalogItem("plugin-downloaded", { family: "code-plugin", updatedAt: 200, stats: { downloads: 100, installs: 5, stars: 0, versions: 1 }, }); - const skillPackage = makeCatalogItem("skill-installed", { + const skillPackage = makeCatalogItem("skill-downloaded", { family: "skill", updatedAt: 100, - stats: { downloads: 1, installs: 40, stars: 0, versions: 1 }, + stats: { downloads: 1_000, installs: 40, stars: 0, versions: 1 }, }); const runQuery = vi.fn((_, args: Record) => { - expect(args).toEqual(expect.objectContaining({ sort: "installs" })); + expect(args).toEqual(expect.objectContaining({ sort: "downloads" })); if (Object.hasOwn(args, "viewerUserId")) { return { page: [pluginPackage], isDone: true, continueCursor: "" }; } @@ -8382,18 +8383,95 @@ describe("httpApiV1 handlers", () => { const response = await __handlers.listPackagesV1Handler( makeCtx({ runQuery, runMutation }), - new Request("https://example.com/api/v1/packages?limit=2&sort=installs"), + new Request("https://example.com/api/v1/packages?limit=2&sort=downloads"), ); expect(response.status).toBe(200); const json = await response.json(); expect(json.items.map((entry: { name: string }) => entry.name)).toEqual([ - "skill-installed", - "plugin-installed", + "skill-downloaded", + "plugin-downloaded", ]); expect(runQuery).toHaveBeenCalledTimes(2); }); + it("packages list maps legacy install sort to downloads", async () => { + const runQuery = vi.fn((_, args: Record) => { + expect(args).toEqual( + expect.objectContaining({ + sort: "downloads", + paginationOpts: expect.objectContaining({ cursor: null }), + }), + ); + return { page: [], isDone: true, continueCursor: "" }; + }); + const runMutation = vi.fn().mockResolvedValue(okRate()); + + const response = await __handlers.listPackagesV1Handler( + makeCtx({ runQuery, runMutation }), + new Request("https://example.com/api/v1/packages?limit=2&sort=installs&cursor=old-install"), + ); + + expect(response.status).toBe(200); + expect(runQuery).toHaveBeenCalledTimes(2); + }); + + it("packages list paginates fresh legacy install sort cursors", async () => { + const firstPackage = makeCatalogItem("first-download", { + family: "code-plugin", + updatedAt: 200, + stats: { downloads: 100, installs: 1, stars: 0, versions: 1 }, + }); + const secondPackage = makeCatalogItem("second-download", { + family: "code-plugin", + updatedAt: 100, + stats: { downloads: 50, installs: 500, stars: 0, versions: 1 }, + }); + const packageCursors: Array = []; + const runQuery = vi.fn((_, args: Record) => { + expect(args).toEqual(expect.objectContaining({ sort: "downloads" })); + if (Object.hasOwn(args, "viewerUserId")) { + const cursor = (args.paginationOpts as { cursor: string | null }).cursor; + packageCursors.push(cursor); + if (cursor === null) { + return { page: [firstPackage], isDone: false, continueCursor: "downloads-cursor" }; + } + if (cursor === "downloads-cursor") { + return { page: [secondPackage], isDone: true, continueCursor: "" }; + } + throw new Error(`unexpected package cursor ${cursor}`); + } + return { page: [], isDone: true, continueCursor: "" }; + }); + const runMutation = vi.fn().mockResolvedValue(okRate()); + + const firstResponse = await __handlers.listPackagesV1Handler( + makeCtx({ runQuery, runMutation }), + new Request("https://example.com/api/v1/packages?limit=1&sort=installs"), + ); + expect(firstResponse.status).toBe(200); + const firstJson = await firstResponse.json(); + expect(firstJson.items.map((entry: { name: string }) => entry.name)).toEqual([ + "first-download", + ]); + expect(firstJson.nextCursor).toMatch(/^pkgcatalog:/); + + const secondUrl = new URL("https://example.com/api/v1/packages"); + secondUrl.searchParams.set("limit", "1"); + secondUrl.searchParams.set("sort", "installs"); + secondUrl.searchParams.set("cursor", firstJson.nextCursor); + const secondResponse = await __handlers.listPackagesV1Handler( + makeCtx({ runQuery, runMutation }), + new Request(secondUrl), + ); + expect(secondResponse.status).toBe(200); + const secondJson = await secondResponse.json(); + expect(secondJson.items.map((entry: { name: string }) => entry.name)).toEqual([ + "second-download", + ]); + expect(packageCursors).toEqual([null, "downloads-cursor"]); + }); + it("packages list recommended sort merges package and skill rows by recommendation score", async () => { const pluginPackage = makeCatalogItem("plugin-downloaded", { family: "code-plugin", @@ -8429,13 +8507,13 @@ describe("httpApiV1 handlers", () => { expect(runQuery).toHaveBeenCalledTimes(4); }); - it("packages list recommended fallback merges package and skill rows by installs", async () => { - const pluginPackage = makeCatalogItem("plugin-low-install-score", { + it("packages list recommended fallback merges package and skill rows by downloads", async () => { + const pluginPackage = makeCatalogItem("plugin-low-download-score", { family: "code-plugin", updatedAt: 300, stats: { downloads: 1, installs: 1, stars: 0, versions: 1 }, }); - const skillPackage = makeCatalogItem("skill-high-install-score", { + const skillPackage = makeCatalogItem("skill-high-download-score", { family: "skill", updatedAt: 200, stats: { downloads: 10, installs: 100, stars: 0, versions: 1 }, @@ -8443,10 +8521,10 @@ describe("httpApiV1 handlers", () => { const runQuery = vi.fn((_, args: Record) => { if (Object.keys(args).length === 0) return true; if (Object.hasOwn(args, "viewerUserId")) { - expect(args).toEqual(expect.objectContaining({ sort: "installs" })); + expect(args).toEqual(expect.objectContaining({ sort: "downloads" })); return { page: [pluginPackage], isDone: false, continueCursor: "packages-next" }; } - expect(args).toEqual(expect.objectContaining({ sort: "installs" })); + expect(args).toEqual(expect.objectContaining({ sort: "downloads" })); return { page: [skillPackage], isDone: false, continueCursor: "skills-next" }; }); const runMutation = vi.fn().mockResolvedValue(okRate()); @@ -8459,9 +8537,9 @@ describe("httpApiV1 handlers", () => { expect(response.status).toBe(200); const json = await response.json(); expect(json.items.map((entry: { name: string }) => entry.name)).toEqual([ - "skill-high-install-score", + "skill-high-download-score", ]); - expect(json.nextCursor).toContain('"recommendedFallback":"installs"'); + expect(json.nextCursor).toContain('"recommendedFallback":"downloads"'); expect(runQuery).toHaveBeenCalledTimes(4); }); @@ -8504,6 +8582,53 @@ describe("httpApiV1 handlers", () => { expect(json.nextCursor).toContain('"recommendedFallback":"updated"'); }); + it("packages list resets legacy installs fallback cursors before using downloads", async () => { + const fallbackCursor = `pkgcatalog:${JSON.stringify({ + packages: { cursor: "legacy-package-install-next", offset: 2, pageSize: 1, done: false }, + skills: { cursor: "legacy-skill-install-next", offset: 1, pageSize: 1, done: false }, + recommendedFallback: "installs", + })}`; + const pluginPackage = makeCatalogItem("plugin-next", { + family: "code-plugin", + updatedAt: 100, + stats: { downloads: 10, installs: 50_000, stars: 0, versions: 1 }, + }); + const seen = { packages: false, skills: false }; + const runQuery = vi.fn((_, args: Record) => { + if (Object.keys(args).length === 0) { + throw new Error("readiness should come from the pagination cursor"); + } + expect(args).toEqual( + expect.objectContaining({ + sort: "downloads", + paginationOpts: expect.objectContaining({ cursor: null }), + }), + ); + if (Object.hasOwn(args, "viewerUserId")) { + seen.packages = true; + return { page: [pluginPackage], isDone: true, continueCursor: "" }; + } + seen.skills = true; + return { page: [], isDone: true, continueCursor: "" }; + }); + const runMutation = vi.fn().mockResolvedValue(okRate()); + + const response = await __handlers.listPackagesV1Handler( + makeCtx({ runQuery, runMutation }), + new Request( + `https://example.com/api/v1/packages?limit=1&sort=recommended&cursor=${encodeURIComponent( + fallbackCursor, + )}`, + ), + ); + + expect(response.status).toBe(200); + const json = await response.json(); + expect(json.items.map((entry: { name: string }) => entry.name)).toEqual(["plugin-next"]); + expect(json.nextCursor).toBeNull(); + expect(seen).toEqual({ packages: true, skills: true }); + }); + it("plugins list defaults to plugin package families", async () => { const codePlugin = { name: "code-plugin", @@ -8704,29 +8829,29 @@ describe("httpApiV1 handlers", () => { } }); - it("plugin and package lists reject invalid downloads sort", async () => { + it("plugin and package lists reject invalid sort values", async () => { const runQuery = vi.fn(); const runMutation = vi.fn().mockResolvedValue(okRate()); const packageResponse = await __handlers.listPackagesV1Handler( makeCtx({ runQuery, runMutation }), new Request( - "https://example.com/api/v1/packages?family=code-plugin&sort=downloads&cursor=invalid-download-cursor", + "https://example.com/api/v1/packages?family=code-plugin&sort=popular&cursor=invalid-sort-cursor", ), ); const pluginResponse = await __handlers.listPluginsV1Handler( makeCtx({ runQuery, runMutation }), new Request( - `https://example.com/api/v1/plugins?sort=downloads&cursor=${encodeURIComponent( + `https://example.com/api/v1/plugins?sort=popular&cursor=${encodeURIComponent( `pkgplugins:${JSON.stringify({ codePlugins: { - cursor: "invalid-code-download-cursor", + cursor: "invalid-code-sort-cursor", offset: 0, pageSize: 25, done: false, }, bundlePlugins: { - cursor: "invalid-bundle-download-cursor", + cursor: "invalid-bundle-sort-cursor", offset: 0, pageSize: 25, done: false, @@ -8743,7 +8868,7 @@ describe("httpApiV1 handlers", () => { expect(runQuery).not.toHaveBeenCalled(); }); - it("plugins list defaults filtered browse to installs sort", async () => { + it("plugins list defaults filtered browse to downloads sort", async () => { const readinessCalls: unknown[] = []; const runQuery = vi.fn((_, args: Record) => { if (hasPluginRecommendedScoreReadinessArgs(args)) { @@ -8766,14 +8891,111 @@ describe("httpApiV1 handlers", () => { expect(args).toEqual( expect.objectContaining({ category: "tools", - sort: "installs", + sort: "downloads", paginationOpts: { cursor: null, numItems: 7 }, }), ); } }); - it("plugins list defaults featured browse to installs sort", async () => { + it("plugins list drops unmarked filtered cursors from the retired installs default", async () => { + const staleCursor = `pkgplugins:${JSON.stringify({ + codePlugins: { cursor: "legacy-code-install-cursor", offset: 0, pageSize: 1, done: false }, + bundlePlugins: { + cursor: "legacy-bundle-install-cursor", + offset: 0, + pageSize: 1, + done: false, + }, + })}`; + const runQuery = vi.fn((_, args: Record) => { + if (hasPluginRecommendedScoreReadinessArgs(args)) { + throw new Error("downloads default should not check recommendation readiness"); + } + expect(args).toEqual( + expect.objectContaining({ + sort: "downloads", + paginationOpts: expect.objectContaining({ cursor: null }), + }), + ); + return { page: [], isDone: true, continueCursor: "" }; + }); + const runMutation = vi.fn().mockResolvedValue(okRate()); + + const response = await __handlers.listPluginsV1Handler( + makeCtx({ runQuery, runMutation }), + new Request( + `https://example.com/api/v1/plugins?category=tools&limit=2&cursor=${encodeURIComponent( + staleCursor, + )}`, + ), + ); + + expect(response.status).toBe(200); + expect(runQuery).toHaveBeenCalledTimes(2); + }); + + it("plugins list paginates fresh filtered default downloads cursors without explicit sort", async () => { + const firstPlugin = makeCatalogItem("first-plugin", { + family: "code-plugin", + updatedAt: 200, + stats: { downloads: 100, installs: 1, stars: 0, versions: 1 }, + }); + const secondPlugin = makeCatalogItem("second-plugin", { + family: "code-plugin", + updatedAt: 100, + stats: { downloads: 50, installs: 500, stars: 0, versions: 1 }, + }); + const codePluginCursors: Array = []; + const runQuery = vi.fn((_, args: Record) => { + if (hasPluginRecommendedScoreReadinessArgs(args)) { + throw new Error("downloads default should not check recommendation readiness"); + } + expect(args).toEqual(expect.objectContaining({ category: "tools", sort: "downloads" })); + if (args.family === "code-plugin") { + const cursor = (args.paginationOpts as { cursor: string | null }).cursor; + codePluginCursors.push(cursor); + if (cursor === null) { + return { page: [firstPlugin], isDone: false, continueCursor: "downloads-cursor" }; + } + if (cursor === "downloads-cursor") { + return { page: [secondPlugin], isDone: true, continueCursor: "" }; + } + throw new Error(`unexpected code plugin cursor ${cursor}`); + } + if (args.family === "bundle-plugin") { + return { page: [], isDone: true, continueCursor: "" }; + } + throw new Error(`unexpected family ${String(args.family)}`); + }); + const runMutation = vi.fn().mockResolvedValue(okRate()); + + const firstResponse = await __handlers.listPluginsV1Handler( + makeCtx({ runQuery, runMutation }), + new Request("https://example.com/api/v1/plugins?category=tools&limit=1"), + ); + expect(firstResponse.status).toBe(200); + const firstJson = await firstResponse.json(); + expect(firstJson.items.map((entry: { name: string }) => entry.name)).toEqual(["first-plugin"]); + expect(firstJson.nextCursor).toMatch(/^pkgplugins:/); + + const secondUrl = new URL("https://example.com/api/v1/plugins"); + secondUrl.searchParams.set("category", "tools"); + secondUrl.searchParams.set("limit", "1"); + secondUrl.searchParams.set("cursor", firstJson.nextCursor); + const secondResponse = await __handlers.listPluginsV1Handler( + makeCtx({ runQuery, runMutation }), + new Request(secondUrl), + ); + expect(secondResponse.status).toBe(200); + const secondJson = await secondResponse.json(); + expect(secondJson.items.map((entry: { name: string }) => entry.name)).toEqual([ + "second-plugin", + ]); + expect(codePluginCursors).toEqual([null, "downloads-cursor"]); + }); + + it("plugins list defaults featured browse to downloads sort", async () => { const readinessCalls: unknown[] = []; const runQuery = vi.fn((_, args: Record) => { if (hasPluginRecommendedScoreReadinessArgs(args)) { @@ -8796,20 +9018,20 @@ describe("httpApiV1 handlers", () => { expect(args).toEqual( expect.objectContaining({ highlightedOnly: true, - sort: "installs", + sort: "downloads", paginationOpts: { cursor: null, numItems: 7 }, }), ); } }); - it("official plugins install sort forwards the filter to both families and merges by installs", async () => { - const codePlugin = makeCatalogItem("code-installed", { + it("plugins list downloads sort forwards to both plugin families and merges by downloads", async () => { + const codePlugin = makeCatalogItem("code-low-download", { family: "code-plugin", updatedAt: 100, stats: { downloads: 1, installs: 50, stars: 0, versions: 1 }, }); - const bundlePlugin = makeCatalogItem("bundle-installed", { + const bundlePlugin = makeCatalogItem("bundle-downloaded", { family: "bundle-plugin", updatedAt: 200, stats: { downloads: 100, installs: 5, stars: 0, versions: 1 }, @@ -8817,7 +9039,45 @@ describe("httpApiV1 handlers", () => { const runQuery = vi.fn((_, args: Record) => { if (Object.keys(args).length === 0) return 2; if (hasPluginRecommendedScoreReadinessArgs(args)) return false; - expect(args).toEqual(expect.objectContaining({ isOfficial: true, sort: "installs" })); + expect(args).toEqual(expect.objectContaining({ sort: "downloads" })); + if (args.family === "code-plugin") { + return { page: [codePlugin], isDone: true, continueCursor: "" }; + } + if (args.family === "bundle-plugin") { + return { page: [bundlePlugin], isDone: true, continueCursor: "" }; + } + throw new Error(`unexpected family ${String(args.family)}`); + }); + const runMutation = vi.fn().mockResolvedValue(okRate()); + + const response = await __handlers.listPluginsV1Handler( + makeCtx({ runQuery, runMutation }), + new Request("https://example.com/api/v1/plugins?limit=2&sort=downloads"), + ); + + expect(response.status).toBe(200); + const json = await response.json(); + expect(json.items.map((entry: { name: string }) => entry.name)).toEqual([ + "bundle-downloaded", + "code-low-download", + ]); + }); + + it("official plugins legacy install sort forwards the filter and maps to downloads", async () => { + const codePlugin = makeCatalogItem("code-installed", { + family: "code-plugin", + updatedAt: 100, + stats: { downloads: 1, installs: 50, stars: 0, versions: 1 }, + }); + const bundlePlugin = makeCatalogItem("bundle-downloaded", { + family: "bundle-plugin", + updatedAt: 200, + stats: { downloads: 100, installs: 5, stars: 0, versions: 1 }, + }); + const runQuery = vi.fn((_, args: Record) => { + if (Object.keys(args).length === 0) return 2; + if (hasPluginRecommendedScoreReadinessArgs(args)) return false; + expect(args).toEqual(expect.objectContaining({ isOfficial: true, sort: "downloads" })); if (args.family === "code-plugin") { return { page: [codePlugin], isDone: true, continueCursor: "" }; } @@ -8836,8 +9096,8 @@ describe("httpApiV1 handlers", () => { expect(response.status).toBe(200); const json = await response.json(); expect(json.items.map((entry: { name: string }) => entry.name)).toEqual([ + "bundle-downloaded", "code-installed", - "bundle-installed", ]); }); @@ -8870,6 +9130,95 @@ describe("httpApiV1 handlers", () => { } }); + it("plugins list maps legacy install sort to downloads and drops legacy cursors", async () => { + const legacyCursor = `pkgplugins:${JSON.stringify({ + codePlugins: { cursor: "old-code-install-cursor", offset: 0, pageSize: 1, done: false }, + bundlePlugins: { cursor: "old-bundle-install-cursor", offset: 0, pageSize: 1, done: false }, + })}`; + const runQuery = vi.fn((_, args: Record) => { + if (Object.keys(args).length === 0) return 2; + expect(args).toEqual( + expect.objectContaining({ + sort: "downloads", + paginationOpts: expect.objectContaining({ cursor: null }), + }), + ); + return { page: [], isDone: true, continueCursor: "" }; + }); + const runMutation = vi.fn().mockResolvedValue(okRate()); + + const response = await __handlers.listPluginsV1Handler( + makeCtx({ runQuery, runMutation }), + new Request( + `https://example.com/api/v1/plugins?limit=2&sort=installs&cursor=${encodeURIComponent( + legacyCursor, + )}`, + ), + ); + + expect(response.status).toBe(200); + expect(runQuery).toHaveBeenCalledTimes(3); + }); + + it("plugins list paginates fresh legacy install sort cursors", async () => { + const firstPlugin = makeCatalogItem("first-plugin", { + family: "code-plugin", + updatedAt: 200, + stats: { downloads: 100, installs: 1, stars: 0, versions: 1 }, + }); + const secondPlugin = makeCatalogItem("second-plugin", { + family: "code-plugin", + updatedAt: 100, + stats: { downloads: 50, installs: 500, stars: 0, versions: 1 }, + }); + const codePluginCursors: Array = []; + const runQuery = vi.fn((_, args: Record) => { + if (Object.keys(args).length === 0) return 2; + if (hasPluginRecommendedScoreReadinessArgs(args)) return false; + expect(args).toEqual(expect.objectContaining({ sort: "downloads" })); + if (args.family === "code-plugin") { + const cursor = (args.paginationOpts as { cursor: string | null }).cursor; + codePluginCursors.push(cursor); + if (cursor === null) { + return { page: [firstPlugin], isDone: false, continueCursor: "code-downloads-cursor" }; + } + if (cursor === "code-downloads-cursor") { + return { page: [secondPlugin], isDone: true, continueCursor: "" }; + } + throw new Error(`unexpected code plugin cursor ${cursor}`); + } + if (args.family === "bundle-plugin") { + return { page: [], isDone: true, continueCursor: "" }; + } + throw new Error(`unexpected family ${String(args.family)}`); + }); + const runMutation = vi.fn().mockResolvedValue(okRate()); + + const firstResponse = await __handlers.listPluginsV1Handler( + makeCtx({ runQuery, runMutation }), + new Request("https://example.com/api/v1/plugins?limit=1&sort=installs"), + ); + expect(firstResponse.status).toBe(200); + const firstJson = await firstResponse.json(); + expect(firstJson.items.map((entry: { name: string }) => entry.name)).toEqual(["first-plugin"]); + expect(firstJson.nextCursor).toMatch(/^pkgplugins:/); + + const secondUrl = new URL("https://example.com/api/v1/plugins"); + secondUrl.searchParams.set("limit", "1"); + secondUrl.searchParams.set("sort", "installs"); + secondUrl.searchParams.set("cursor", firstJson.nextCursor); + const secondResponse = await __handlers.listPluginsV1Handler( + makeCtx({ runQuery, runMutation }), + new Request(secondUrl), + ); + expect(secondResponse.status).toBe(200); + const secondJson = await secondResponse.json(); + expect(secondJson.items.map((entry: { name: string }) => entry.name)).toEqual([ + "second-plugin", + ]); + expect(codePluginCursors).toEqual([null, "code-downloads-cursor"]); + }); + it("plugins list recommended sort uses weighted scores across plugin families", async () => { const codePlugin = makeCatalogItem("code-starred", { family: "code-plugin", @@ -8946,7 +9295,7 @@ describe("httpApiV1 handlers", () => { ]); }); - it("plugins list falls back to installs sort while recommendation scores backfill", async () => { + it("plugins list falls back to downloads sort while recommendation scores backfill", async () => { const codePlugin = makeCatalogItem("code-older-high-score", { family: "code-plugin", updatedAt: 100, @@ -8960,7 +9309,7 @@ describe("httpApiV1 handlers", () => { const runQuery = vi.fn((_, args: Record) => { if (Object.keys(args).length === 0) return 2; if (hasPluginRecommendedScoreReadinessArgs(args)) return true; - expect(args).toEqual(expect.objectContaining({ sort: "installs" })); + expect(args).toEqual(expect.objectContaining({ sort: "downloads" })); if (args.family === "code-plugin") { return { page: [codePlugin], isDone: true, continueCursor: "" }; } @@ -8984,10 +9333,10 @@ describe("httpApiV1 handlers", () => { ]); }); - it("plugins list keeps installs fallback sort from recommended pagination cursors", async () => { + it("plugins list maps legacy installs fallback sort from recommended pagination cursors", async () => { const fallbackCursor = `pkgplugins:${JSON.stringify({ - codePlugins: { cursor: null, offset: 0, pageSize: 1, done: false }, - bundlePlugins: { cursor: null, offset: 0, pageSize: 1, done: true }, + codePlugins: { cursor: "legacy-code-install-next", offset: 2, pageSize: 1, done: false }, + bundlePlugins: { cursor: "legacy-bundle-install-next", offset: 1, pageSize: 1, done: true }, recommendedFallback: "installs", })}`; const codePlugin = makeCatalogItem("code-next", { @@ -8995,15 +9344,26 @@ describe("httpApiV1 handlers", () => { updatedAt: 100, stats: { downloads: 50_000, installs: 500, stars: 10, versions: 1 }, }); + const seen = { codePlugin: false, bundlePlugin: false }; const runQuery = vi.fn((_, args: Record) => { if (Object.keys(args).length === 0) return 1; if (hasPluginRecommendedScoreReadinessArgs(args)) { throw new Error("readiness should come from the pagination cursor"); } - expect(args).toEqual(expect.objectContaining({ sort: "installs" })); + expect(args).toEqual( + expect.objectContaining({ + sort: "downloads", + paginationOpts: expect.objectContaining({ cursor: null }), + }), + ); if (args.family === "code-plugin") { + seen.codePlugin = true; return { page: [codePlugin], isDone: true, continueCursor: "" }; } + if (args.family === "bundle-plugin") { + seen.bundlePlugin = true; + return { page: [], isDone: true, continueCursor: "" }; + } throw new Error(`unexpected family ${String(args.family)}`); }); const runMutation = vi.fn().mockResolvedValue(okRate()); @@ -9020,6 +9380,8 @@ describe("httpApiV1 handlers", () => { expect(response.status).toBe(200); const json = await response.json(); expect(json.items.map((entry: { name: string }) => entry.name)).toEqual(["code-next"]); + expect(json.nextCursor).toBeNull(); + expect(seen).toEqual({ codePlugin: true, bundlePlugin: true }); }); it("plugins list keeps legacy updated fallback sort from recommended pagination cursors", async () => { diff --git a/convex/httpApiV1/packagesV1.ts b/convex/httpApiV1/packagesV1.ts index 650c667f..4db13038 100644 --- a/convex/httpApiV1/packagesV1.ts +++ b/convex/httpApiV1/packagesV1.ts @@ -309,7 +309,7 @@ async function getOptionalViewerUserIdForRequest(ctx: ActionCtx, request: Reques const PACKAGE_FAMILY_VALUES = ["skill", "code-plugin", "bundle-plugin"] as const; const PLUGIN_EXPORT_FAMILY_VALUES = ["code-plugin", "bundle-plugin"] as const; const PACKAGE_CHANNEL_VALUES = ["official", "community", "private"] as const; -const PACKAGE_LIST_SORT_VALUES = ["updated", "recommended", "installs"] as const; +const PACKAGE_LIST_SORT_VALUES = ["updated", "recommended", "downloads", "installs"] as const; const PACKAGE_SCAN_STATUS_VALUES = [ "clean", "suspicious", @@ -846,15 +846,23 @@ type UnifiedCatalogCursorState = { packages: CatalogSourceCursorState; skills: CatalogSourceCursorState; recommendedFallback?: RecommendedFallbackSort; + legacyInstallSort?: LegacyInstallSortMarker; }; type PluginCatalogCursorState = { codePlugins: CatalogSourceCursorState; bundlePlugins: CatalogSourceCursorState; recommendedFallback?: RecommendedFallbackSort; + legacyInstallSort?: LegacyInstallSortMarker; }; -type RecommendedFallbackSort = "updated" | "installs"; +type RecommendedFallbackSort = "updated" | "downloads"; +type LegacyInstallSortMarker = "downloads"; + +type PackagePageCursorState = { + cursor: string | null; + legacyInstallSort?: LegacyInstallSortMarker; +}; type CatalogPageResult = { page: T[]; @@ -874,7 +882,7 @@ const PLUGIN_CATALOG_CURSOR_PREFIX = "pkgplugins:"; const LEGACY_PLUGIN_SEARCH_CURSOR_PREFIX = "pkgpluginsearch:"; const SKILL_CATALOG_CURSOR_PREFIX = "skillcat:"; const PACKAGE_PAGE_CURSOR_PREFIX = "pkgpage:"; -const RECOMMENDED_FALLBACK_SORT = "installs" as const; +const RECOMMENDED_FALLBACK_SORT = "downloads" as const; const CATALOG_CURSOR_PREFIXES = [ UNIFIED_CATALOG_CURSOR_PREFIX, PLUGIN_CATALOG_CURSOR_PREFIX, @@ -884,6 +892,7 @@ const CATALOG_CURSOR_PREFIXES = [ ]; function normalizeRecommendedFallbackSort(value: unknown): RecommendedFallbackSort | undefined { + if (value === "installs") return "downloads"; return value === "updated" || value === RECOMMENDED_FALLBACK_SORT ? value : undefined; } @@ -891,6 +900,24 @@ function defaultCatalogSourceCursorState(): CatalogSourceCursorState { return { cursor: null, offset: 0, pageSize: null, done: false }; } +function readObjectField(input: unknown, field: string): unknown { + if (input === null || typeof input !== "object") return undefined; + return Object.getOwnPropertyDescriptor(input, field)?.value; +} + +function normalizeCatalogSourceCursorState(input: unknown): CatalogSourceCursorState { + const cursor = readObjectField(input, "cursor"); + const offset = readObjectField(input, "offset"); + const pageSize = readObjectField(input, "pageSize"); + const done = readObjectField(input, "done"); + return { + cursor: typeof cursor === "string" ? cursor : null, + offset: typeof offset === "number" && offset > 0 ? offset : 0, + pageSize: typeof pageSize === "number" && pageSize > 0 ? pageSize : null, + done: done === true, + }; +} + function encodeUnifiedCatalogCursor(state: UnifiedCatalogCursorState) { return `${UNIFIED_CATALOG_CURSOR_PREFIX}${JSON.stringify(state)}`; } @@ -910,21 +937,17 @@ function decodeUnifiedCatalogCursor(raw: string | null | undefined): UnifiedCata }; } try { - const parsed = JSON.parse( - raw.slice(UNIFIED_CATALOG_CURSOR_PREFIX.length), - ) as Partial; - const normalize = ( - input: Partial | undefined, - ): CatalogSourceCursorState => ({ - cursor: typeof input?.cursor === "string" ? input.cursor : null, - offset: typeof input?.offset === "number" && input.offset > 0 ? input.offset : 0, - pageSize: typeof input?.pageSize === "number" && input.pageSize > 0 ? input.pageSize : null, - done: input?.done === true, - }); + const parsed: unknown = JSON.parse(raw.slice(UNIFIED_CATALOG_CURSOR_PREFIX.length)); + const recommendedFallbackValue = readObjectField(parsed, "recommendedFallback"); + const resetLegacyInstallCursorState = recommendedFallbackValue === "installs"; return { - packages: normalize(parsed.packages), - skills: normalize(parsed.skills), - recommendedFallback: normalizeRecommendedFallbackSort(parsed.recommendedFallback), + packages: resetLegacyInstallCursorState + ? defaultCatalogSourceCursorState() + : normalizeCatalogSourceCursorState(readObjectField(parsed, "packages")), + skills: resetLegacyInstallCursorState + ? defaultCatalogSourceCursorState() + : normalizeCatalogSourceCursorState(readObjectField(parsed, "skills")), + recommendedFallback: normalizeRecommendedFallbackSort(recommendedFallbackValue), }; } catch { return { @@ -938,19 +961,44 @@ function encodePluginCatalogCursor(state: PluginCatalogCursorState) { return `${PLUGIN_CATALOG_CURSOR_PREFIX}${JSON.stringify(state)}`; } +function encodePackagePageCursor(state: PackagePageCursorState) { + return `${PACKAGE_PAGE_CURSOR_PREFIX}${JSON.stringify(state)}`; +} + +function parsePrefixedCursorPayload(raw: string | null | undefined, prefix: string): unknown { + if (!raw?.startsWith(prefix)) return null; + try { + return JSON.parse(raw.slice(prefix.length)); + } catch { + return null; + } +} + +function hasDownloadsMappedLegacyInstallCursor(raw: string | null | undefined, prefix: string) { + const payload = parsePrefixedCursorPayload(raw, prefix); + return readObjectField(payload, "legacyInstallSort") === "downloads"; +} + +function normalizeLegacyInstallAggregateCursor(raw: string | null, prefix: string) { + if (!raw) return null; + return hasDownloadsMappedLegacyInstallCursor(raw, prefix) ? raw : null; +} + +function decodeLegacyInstallPageCursor(raw: string | null) { + const payload = parsePrefixedCursorPayload(raw, PACKAGE_PAGE_CURSOR_PREFIX); + if (readObjectField(payload, "legacyInstallSort") !== "downloads") return null; + const cursor = readObjectField(payload, "cursor"); + return typeof cursor === "string" ? cursor : null; +} + +function legacyInstallSortMarker(isLegacyInstallSortRequest: boolean) { + return isLegacyInstallSortRequest ? ("downloads" as const) : undefined; +} + function decodeMultiPluginCursor( raw: string | null | undefined, prefix: string, ): PluginCatalogCursorState { - const normalize = ( - input: Partial | undefined, - ): CatalogSourceCursorState => ({ - cursor: typeof input?.cursor === "string" ? input.cursor : null, - offset: typeof input?.offset === "number" && input.offset > 0 ? input.offset : 0, - pageSize: typeof input?.pageSize === "number" && input.pageSize > 0 ? input.pageSize : null, - done: input?.done === true, - }); - if (!raw?.startsWith(prefix)) { return { codePlugins: { @@ -961,11 +1009,17 @@ function decodeMultiPluginCursor( }; } try { - const parsed = JSON.parse(raw.slice(prefix.length)) as Partial; + const parsed: unknown = JSON.parse(raw.slice(prefix.length)); + const recommendedFallbackValue = readObjectField(parsed, "recommendedFallback"); + const resetLegacyInstallCursorState = recommendedFallbackValue === "installs"; return { - codePlugins: normalize(parsed.codePlugins), - bundlePlugins: normalize(parsed.bundlePlugins), - recommendedFallback: normalizeRecommendedFallbackSort(parsed.recommendedFallback), + codePlugins: resetLegacyInstallCursorState + ? defaultCatalogSourceCursorState() + : normalizeCatalogSourceCursorState(readObjectField(parsed, "codePlugins")), + bundlePlugins: resetLegacyInstallCursorState + ? defaultCatalogSourceCursorState() + : normalizeCatalogSourceCursorState(readObjectField(parsed, "bundlePlugins")), + recommendedFallback: normalizeRecommendedFallbackSort(recommendedFallbackValue), }; } catch { return { @@ -1043,6 +1097,10 @@ function compareCatalogItems(a: CatalogListItem, b: CatalogListItem) { return a.name.localeCompare(b.name); } +function normalizePublicPackageSort(sort: (typeof PACKAGE_LIST_SORT_VALUES)[number] | undefined) { + return sort === "installs" ? "downloads" : sort; +} + function compareCatalogItemsForSort( a: CatalogListItem, b: CatalogListItem, @@ -1063,9 +1121,9 @@ function compareCatalogItemsForSort( ); if (score !== 0) return score; } - if (sort === "installs") { - const installs = (b.stats?.installs ?? 0) - (a.stats?.installs ?? 0); - if (installs !== 0) return installs; + if (sort === "downloads" || sort === "installs") { + const downloads = (b.stats?.downloads ?? 0) - (a.stats?.downloads ?? 0); + if (downloads !== 0) return downloads; } return compareCatalogItems(a, b); } @@ -1497,7 +1555,6 @@ async function listPackages( if (!highlightedOnlyParam.ok) return text(highlightedOnlyParam.message, 400, rate.headers); const sortParam = parseEnumQueryParam(url.searchParams, "sort", PACKAGE_LIST_SORT_VALUES); if (!sortParam.ok) return text(sortParam.message, 400, rate.headers); - const cursor = rawCursor; const rawCategory = url.searchParams.get("category")?.trim() || undefined; const category = resolvePluginCategoryFilter(rawCategory); const topic = url.searchParams.get("topic")?.trim().toLowerCase() || undefined; @@ -1520,7 +1577,8 @@ async function listPackages( (highlightedOnly || category) ? RECOMMENDED_FALLBACK_SORT : options?.defaultSort; - const effectiveSort = sortParam.value ?? pluginDefaultSort; + const isLegacyInstallSortRequest = sortParam.value === "installs"; + const effectiveSort = normalizePublicPackageSort(sortParam.value ?? pluginDefaultSort); if (category && (effectiveFamily === "skill" || (!effectiveFamily && includeSkills))) { return text( "Plugin category is only supported for plugin package endpoints", @@ -1530,6 +1588,9 @@ async function listPackages( } if (effectiveFamily === "skill") { + const cursor = isLegacyInstallSortRequest + ? decodeLegacyInstallPageCursor(rawCursor) + : rawCursor; const result = await runQueryRef<{ page: CatalogListItem[]; isDone: boolean; @@ -1543,13 +1604,26 @@ async function listPackages( paginationOpts: { cursor, numItems: limit }, }); return json( - { items: result.page, nextCursor: result.isDone ? null : result.continueCursor }, + { + items: result.page, + nextCursor: result.isDone + ? null + : isLegacyInstallSortRequest + ? encodePackagePageCursor({ + cursor: result.continueCursor, + legacyInstallSort: "downloads", + }) + : result.continueCursor, + }, 200, rate.headers, ); } if (!effectiveFamily && includeSkills) { + const cursor = isLegacyInstallSortRequest + ? normalizeLegacyInstallAggregateCursor(rawCursor, UNIFIED_CATALOG_CURSOR_PREFIX) + : rawCursor; const decodedCursor = decodeUnifiedCatalogCursor(cursor); const isFreshRecommendedRequest = effectiveSort === "recommended" && !cursor; const [hasMissingPackageRecommendationScores, hasMissingSkillRecommendationScores] = @@ -1645,6 +1719,7 @@ async function listPackages( packages: finalizeCatalogSource(packageSource), skills: finalizeCatalogSource(skillSource), recommendedFallback, + legacyInstallSort: legacyInstallSortMarker(isLegacyInstallSortRequest), }; const isDoneAll = nextState.packages.done && @@ -1662,6 +1737,12 @@ async function listPackages( } if (!effectiveFamily && options?.pluginFamilies?.length) { + const shouldMarkDefaultDownloadCursor = + !sortParam.value && pluginDefaultSort === RECOMMENDED_FALLBACK_SORT; + const cursor = + isLegacyInstallSortRequest || shouldMarkDefaultDownloadCursor + ? normalizeLegacyInstallAggregateCursor(rawCursor, PLUGIN_CATALOG_CURSOR_PREFIX) + : rawCursor; const includeTotalCount = !includeSkills && !category && @@ -1764,6 +1845,9 @@ async function listPackages( codePlugins: finalizeCatalogSource(codePluginSource), bundlePlugins: finalizeCatalogSource(bundlePluginSource), recommendedFallback, + legacyInstallSort: legacyInstallSortMarker( + isLegacyInstallSortRequest || shouldMarkDefaultDownloadCursor, + ), }; const isDoneAll = nextState.codePlugins.done && @@ -1781,6 +1865,7 @@ async function listPackages( ); } + const cursor = isLegacyInstallSortRequest ? decodeLegacyInstallPageCursor(rawCursor) : rawCursor; const result = await runQueryRef<{ page: unknown[]; isDone: boolean; @@ -1799,7 +1884,17 @@ async function listPackages( paginationOpts: { cursor, numItems: limit }, } satisfies PackageListQueryArgs); return json( - { items: result.page, nextCursor: result.isDone ? null : result.continueCursor }, + { + items: result.page, + nextCursor: result.isDone + ? null + : isLegacyInstallSortRequest + ? encodePackagePageCursor({ + cursor: result.continueCursor, + legacyInstallSort: "downloads", + }) + : result.continueCursor, + }, 200, rate.headers, ); @@ -3586,11 +3681,13 @@ export async function packagesGetRouterV1Handler(ctx: ActionCtx, request: Reques rate.headers, ); } + const tags = await resolvePackageTags(ctx, publicPackage!.tags); + return json( { package: { ...toPackageDetailResponsePackage(publicPackage!), - tags: await resolvePackageTags(ctx, publicPackage!.tags), + tags, }, owner: packageOwner ? { diff --git a/convex/httpApiV1/skillsV1.ts b/convex/httpApiV1/skillsV1.ts index 201ccd1a..f4e032a3 100644 --- a/convex/httpApiV1/skillsV1.ts +++ b/convex/httpApiV1/skillsV1.ts @@ -1422,17 +1422,9 @@ export async function resolveSkillVersionV1Handler(ctx: ActionCtx, request: Requ ); } -type SkillListSort = - | "recommended" - | "createdAt" - | "updated" - | "downloads" - | "stars" - | "installsCurrent" - | "installsAllTime" - | "trending"; +type SkillListSort = "recommended" | "createdAt" | "updated" | "downloads" | "stars" | "trending"; -type PublicListSort = "recommended" | "newest" | "updated" | "downloads" | "stars" | "installs"; +type PublicListSort = "recommended" | "newest" | "updated" | "downloads" | "stars"; function parseListSort(value: string | null): SkillListSort | null { if (value === null) return "updated"; @@ -1451,10 +1443,10 @@ function parseListSort(value: string | null): SkillListSort | null { normalized === "installscurrent" || normalized === "installs-current" ) { - return "installsCurrent"; + return "downloads"; } if (normalized === "installsalltime" || normalized === "installs-all-time") { - return "installsAllTime"; + return "downloads"; } if (normalized === "trending") return "trending"; if (normalized === "updated") return "updated"; @@ -1462,11 +1454,19 @@ function parseListSort(value: string | null): SkillListSort | null { } function toPublicListSort(sort: Exclude): PublicListSort { - if (sort === "recommended") return "recommended"; - if (sort === "createdAt") return "newest"; - if (sort === "updated") return "updated"; - if (sort === "stars") return sort; - return "installs"; + switch (sort) { + case "recommended": + return "recommended"; + case "createdAt": + return "newest"; + case "updated": + return "updated"; + case "downloads": + return "downloads"; + case "stars": + return "stars"; + } + throw new Error("Unhandled skill list sort"); } export async function listSkillsV1Handler(ctx: ActionCtx, request: Request) { diff --git a/convex/lib/downloadTrend.test.ts b/convex/lib/downloadTrend.test.ts new file mode 100644 index 00000000..0a822390 --- /dev/null +++ b/convex/lib/downloadTrend.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import { ACTIVITY_TREND_DAYS, buildDailyMetricTrends } from "./downloadTrend"; + +describe("download trend helpers", () => { + it("fills missing days and totals the daily activity points", () => { + const trend = buildDailyMetricTrends( + [ + { day: 20, downloads: 3, installs: 1 }, + { day: 22, downloads: 8, installs: 4 }, + { day: 25, downloads: 2, installs: 0 }, + ], + 25, + ); + + expect(trend.downloads.range).toBe("daily"); + expect(trend.downloads.days).toBe(ACTIVITY_TREND_DAYS); + expect(trend.downloads.total).toBe(13); + expect(trend.downloads.points).toHaveLength(ACTIVITY_TREND_DAYS); + expect(trend.downloads.points[0]).toEqual({ day: -4, value: 0 }); + expect(trend.downloads.points.at(-1)).toEqual({ day: 25, value: 2 }); + expect(trend.downloads.points.find((point) => point.day === 20)).toEqual({ + day: 20, + value: 3, + }); + expect(trend.downloads.points.find((point) => point.day === 22)).toEqual({ + day: 22, + value: 8, + }); + }); + + it("shows zero 30-day activity when no daily rows exist", () => { + const trend = buildDailyMetricTrends([], 25); + + expect(trend.downloads.total).toBe(0); + expect(trend.downloads.points).toHaveLength(ACTIVITY_TREND_DAYS); + expect(trend.downloads.points[0]?.day).toBe(-4); + expect(trend.downloads.points.at(-1)?.day).toBe(25); + expect(trend.downloads.points.every((point) => point.value === 0)).toBe(true); + }); +}); diff --git a/convex/lib/downloadTrend.ts b/convex/lib/downloadTrend.ts new file mode 100644 index 00000000..1b8e39b7 --- /dev/null +++ b/convex/lib/downloadTrend.ts @@ -0,0 +1,62 @@ +import { toDayKey } from "./leaderboards"; + +export const ACTIVITY_TREND_DAYS = 30; +export const ACTIVITY_TREND_DAY_MS = 86_400_000; + +type DailyMetricRow = { + day: number; + downloads: number; + installs: number; +}; + +type MetricTrend = { + range: "daily"; + days: number; + total: number; + points: Array<{ day: number; value: number }>; +}; + +export type DailyMetricTrends = { + downloads: MetricTrend; +}; + +export function getActivityTrendRange(now: number) { + return getActivityTrendRangeForEndDay(toDayKey(now)); +} + +export function getActivityTrendRangeForEndDay(endDayValue: number) { + const endDay = Math.trunc(endDayValue); + const startDay = endDay - (ACTIVITY_TREND_DAYS - 1); + const startTime = startDay * ACTIVITY_TREND_DAY_MS; + const endTimeExclusive = (endDay + 1) * ACTIVITY_TREND_DAY_MS; + return { startDay, endDay, startTime, endTimeExclusive }; +} + +export function clampActivityTrendEndDay(endDayValue: number, now: number) { + return Math.min(Math.trunc(endDayValue), toDayKey(now)); +} + +function buildDownloadTrend(rows: DailyMetricRow[], endDay: number): MetricTrend { + const { startDay } = getActivityTrendRangeForEndDay(endDay); + const valuesByDay = new Map(); + for (const row of rows) { + valuesByDay.set(row.day, Math.max(0, row.downloads)); + } + + const points = Array.from({ length: ACTIVITY_TREND_DAYS }, (_, index) => { + const day = startDay + index; + return { + day, + value: valuesByDay.get(day) ?? 0, + }; + }); + + const total = points.reduce((sum, point) => sum + point.value, 0); + return { range: "daily", days: ACTIVITY_TREND_DAYS, total, points }; +} + +export function buildDailyMetricTrends(rows: DailyMetricRow[], endDay: number): DailyMetricTrends { + return { + downloads: buildDownloadTrend(rows, endDay), + }; +} diff --git a/convex/lib/retentionPolicy.test.ts b/convex/lib/retentionPolicy.test.ts index 2d0e3674..81bbad4a 100644 --- a/convex/lib/retentionPolicy.test.ts +++ b/convex/lib/retentionPolicy.test.ts @@ -39,4 +39,19 @@ describe("retention policies", () => { expirationIndex: "by_expires_at", }); }); + + it("documents package daily stats as durable analytics", () => { + expect(getRetentionPolicy("packageDailyStats")).toMatchObject({ + classification: "permanent", + }); + }); + + it("documents package stat events as processed-event retention", () => { + expect(getRetentionPolicy("packageStatEvents")).toMatchObject({ + classification: "ephemeral", + expirationField: "processedAt", + expirationIndex: "by_unprocessed", + prune: "packages.pruneProcessedPackageStatEventsInternal", + }); + }); }); diff --git a/convex/lib/retentionPolicy.ts b/convex/lib/retentionPolicy.ts index a4bc14a9..eabd46cb 100644 --- a/convex/lib/retentionPolicy.ts +++ b/convex/lib/retentionPolicy.ts @@ -122,9 +122,10 @@ export const RETENTION_POLICIES = { packageStatEvents: ephemeral("Package stat event log only needs to survive processing.", { expirationField: "processedAt", expirationIndex: "by_unprocessed", - prune: "pending packageStatEvents retention work", - retention: "After stat processing succeeds.", + prune: "packages.pruneProcessedPackageStatEventsInternal", + retention: "Processed and older than 7 days.", }), + packageDailyStats: permanent("Daily aggregate package stats are product analytics."), packageTrustedPublishers: permanent("Trusted publishing configuration."), packagePublishTokens: ephemeral("Package publish tokens expire and can be revoked.", { expirationField: "expiresAt", diff --git a/convex/maintenance.test.ts b/convex/maintenance.test.ts index 0be04bd1..19f0814d 100644 --- a/convex/maintenance.test.ts +++ b/convex/maintenance.test.ts @@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; vi.mock("@convex-dev/auth/server", () => ({ getAuthUserId: vi.fn(), + authTables: {}, })); vi.mock("./_generated/api", () => ({ diff --git a/convex/packages.public.test.ts b/convex/packages.public.test.ts index 0831c216..41ae5fb3 100644 --- a/convex/packages.public.test.ts +++ b/convex/packages.public.test.ts @@ -63,8 +63,8 @@ import { } from "./packages"; vi.mock("@convex-dev/auth/server", () => ({ - authTables: {}, getAuthUserId: vi.fn(), + authTables: {}, })); type WrappedHandler = { @@ -4097,6 +4097,33 @@ describe("packages public queries", () => { expect(indexNames).toEqual(["by_active_family_category_installs"]); }); + it("uses channel-aware plugin category download indexes", async () => { + const { ctx, indexNames } = makeDigestCtx({ + categoryPages: [ + { + page: [ + makeDigest("community-tools", { + channel: "community", + pluginCategory: "tools", + pluginCategoryTags: ["tools"], + }), + ], + isDone: true, + continueCursor: "", + }, + ], + }); + + await listPublicPageHandler(ctx, { + channel: "community", + category: "tools", + sort: "downloads", + paginationOpts: { cursor: null, numItems: 10 }, + }); + + expect(indexNames).toEqual(["by_active_channel_category_downloads"]); + }); + it("uses family-aware official category sort indexes for official-first sources", async () => { const { ctx, indexNames } = makeDigestCtx({ categoryPages: [ @@ -4234,7 +4261,7 @@ describe("packages public queries", () => { ]); }); - it("uses topic digest sort indexes for filtered listings", async () => { + it("uses family-aware topic digest sort indexes for filtered listings", async () => { const { ctx, indexNames } = makeDigestCtx({ topicPages: [ { @@ -4246,12 +4273,13 @@ describe("packages public queries", () => { }); await listPublicPageHandler(ctx, { + family: "code-plugin", topic: "calendar", sort: "downloads", paginationOpts: { cursor: null, numItems: 10 }, }); - expect(indexNames).toEqual(["by_active_topic_downloads"]); + expect(indexNames).toEqual(["by_active_family_topic_downloads"]); }); it("keeps metadata recommendation fallback cursors on the updated digest index", async () => { diff --git a/convex/packages.stats.test.ts b/convex/packages.stats.test.ts index a22ac97e..3b1f0257 100644 --- a/convex/packages.stats.test.ts +++ b/convex/packages.stats.test.ts @@ -1,16 +1,29 @@ /* @vitest-environment node */ -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { ACTIVITY_TREND_DAYS, getActivityTrendRange } from "./lib/downloadTrend"; import { computeRecommendationScore, RECOMMENDATION_SCORE_VERSION, } from "./lib/recommendationScore"; import { + getActivityTrendForName, + pruneProcessedPackageStatEventBatchInternal, processPackageStatEventsInternal, recordPackageDownloadInternal, recordPackageInstallInternal, } from "./packages"; +vi.mock("@convex-dev/auth/server", () => ({ + getAuthUserId: vi.fn(), + authTables: {}, +})); + +const { getAuthUserId } = await import("@convex-dev/auth/server"); + +const packageDailyStatsRolloutAtEnv = "PACKAGE_DAILY_STATS_ROLLOUT_AT"; +const originalPackageDailyStatsRolloutAt = process.env[packageDailyStatsRolloutAtEnv]; + type WrappedHandler = { _handler: (ctx: unknown, args: TArgs) => Promise; }; @@ -39,7 +52,46 @@ const processStatsHandler = ( > )._handler; +const pruneProcessedPackageStatEventBatchHandler = ( + pruneProcessedPackageStatEventBatchInternal as unknown as WrappedHandler< + { + cutoffProcessedAt: number; + dryRun: boolean; + batchSize?: number; + confirmationToken?: string; + }, + { matched: number; deleted: number; hasMore: boolean } + > +)._handler; + +const getActivityTrendHandler = ( + getActivityTrendForName as unknown as WrappedHandler< + { name: string; endDay: number }, + { + downloads: { + range: "daily"; + days: number; + total: number; + points: Array<{ day: number; value: number }>; + }; + } | null + > +)._handler; + +function setPackageDailyStatsRolloutAt(value: string | undefined) { + if (value === undefined) { + delete process.env[packageDailyStatsRolloutAtEnv]; + return; + } + process.env[packageDailyStatsRolloutAtEnv] = value; +} + describe("package stat events", () => { + afterEach(() => { + vi.mocked(getAuthUserId).mockReset(); + setPackageDailyStatsRolloutAt(originalPackageDailyStatsRolloutAt); + }); + it("records downloads as append-only events", async () => { const insert = vi.fn(); @@ -176,25 +228,57 @@ describe("package stat events", () => { }); it("aggregates queued downloads and installs before patching package stats", async () => { + const dayStart = 86_400_000; const events = [ - { _id: "packageStatEvents:1", packageId: "packages:one", kind: "download" }, - { _id: "packageStatEvents:2", packageId: "packages:one", kind: "install" }, - { _id: "packageStatEvents:3", packageId: "packages:two", kind: "download" }, + { + _id: "packageStatEvents:1", + packageId: "packages:one", + kind: "download", + occurredAt: dayStart, + }, + { + _id: "packageStatEvents:2", + packageId: "packages:one", + kind: "install", + occurredAt: dayStart, + }, + { + _id: "packageStatEvents:3", + packageId: "packages:two", + kind: "download", + occurredAt: dayStart, + }, + { + _id: "packageStatEvents:4", + packageId: "packages:one", + kind: "download", + occurredAt: dayStart * 2, + }, ]; + const insert = vi.fn(); const patch = vi.fn(); const ctx = { db: { - query: vi.fn(() => ({ - withIndex: vi.fn(() => ({ - take: vi.fn(async () => events), - })), - })), + query: vi.fn((tableName: string) => { + if (tableName === "packageStatEvents") { + return { + withIndex: vi.fn(() => ({ + take: vi.fn(async () => events), + })), + }; + } + return { + withIndex: vi.fn(() => ({ + unique: vi.fn(async () => null), + })), + }; + }), get: vi.fn(async (id: string) => ({ _id: id, stats: { downloads: 10, installs: 1, stars: 2, versions: 3 }, })), normalizeId: vi.fn(), - insert: vi.fn(), + insert, patch, replace: vi.fn(), delete: vi.fn(), @@ -210,13 +294,40 @@ describe("package stat events", () => { const result = await processStatsHandler(ctx, { batchSize: 10 }); - expect(result).toEqual({ processed: 3, packagesUpdated: 2 }); + expect(result).toEqual({ processed: 4, packagesUpdated: 2 }); + expect(insert).toHaveBeenCalledWith( + "packageDailyStats", + expect.objectContaining({ + packageId: "packages:one", + day: 1, + downloads: 1, + installs: 1, + }), + ); + expect(insert).toHaveBeenCalledWith( + "packageDailyStats", + expect.objectContaining({ + packageId: "packages:two", + day: 1, + downloads: 1, + installs: 0, + }), + ); + expect(insert).toHaveBeenCalledWith( + "packageDailyStats", + expect.objectContaining({ + packageId: "packages:one", + day: 2, + downloads: 1, + installs: 0, + }), + ); expect(patch).toHaveBeenCalledWith( "packages:one", expect.objectContaining({ - stats: expect.objectContaining({ downloads: 11 }), + stats: expect.objectContaining({ downloads: 12 }), recommendedScore: computeRecommendationScore({ - downloads: 11, + downloads: 12, installs: 2, stars: 2, }), @@ -240,4 +351,617 @@ describe("package stat events", () => { expect.objectContaining({ processedAt: expect.any(Number) }), ); }); + + it("updates an existing package daily stat row for another batch on the same day", async () => { + const dayStart = 86_400_000; + const events = [ + { + _id: "packageStatEvents:1", + packageId: "packages:one", + kind: "download", + occurredAt: dayStart, + }, + { + _id: "packageStatEvents:2", + packageId: "packages:one", + kind: "install", + occurredAt: dayStart, + }, + ]; + const insert = vi.fn(); + const patch = vi.fn(); + const ctx = { + db: { + query: vi.fn((tableName: string) => { + if (tableName === "packageStatEvents") { + return { + withIndex: vi.fn(() => ({ + take: vi.fn(async () => events), + })), + }; + } + if (tableName === "packageDailyStats") { + return { + withIndex: vi.fn(() => ({ + unique: vi.fn(async () => ({ + _id: "packageDailyStats:existing", + downloads: 4, + installs: 2, + })), + })), + }; + } + throw new Error(`Unexpected query table ${tableName}`); + }), + get: vi.fn(async (id: string) => ({ + _id: id, + stats: { downloads: 10, installs: 1, stars: 2, versions: 3 }, + })), + normalizeId: vi.fn(), + insert, + patch, + replace: vi.fn(), + delete: vi.fn(), + system: { + get: vi.fn(), + query: vi.fn(), + }, + }, + scheduler: { + runAfter: vi.fn(), + }, + }; + + const result = await processStatsHandler(ctx, { batchSize: 10 }); + + expect(result).toEqual({ processed: 2, packagesUpdated: 1 }); + expect(insert).not.toHaveBeenCalledWith("packageDailyStats", expect.anything()); + expect(patch).toHaveBeenCalledWith( + "packageDailyStats:existing", + expect.objectContaining({ + downloads: 5, + installs: 3, + updatedAt: expect.any(Number), + }), + ); + }); + + it("caps package stat batches after adding daily stat writes", async () => { + const events = Array.from({ length: 100 }, (_, index) => ({ + _id: `packageStatEvents:${index}`, + packageId: `packages:${index}`, + kind: "download", + occurredAt: 86_400_000, + })); + const take = vi.fn(async () => events); + const patch = vi.fn(); + const runAfter = vi.fn(); + const ctx = { + db: { + query: vi.fn((tableName: string) => { + if (tableName === "packageStatEvents") { + return { + withIndex: vi.fn(() => ({ take })), + }; + } + if (tableName === "packageDailyStats") { + return { + withIndex: vi.fn(() => ({ unique: vi.fn(async () => null) })), + }; + } + throw new Error(`Unexpected query table ${tableName}`); + }), + get: vi.fn(async (id: string) => ({ + _id: id, + stats: { downloads: 0, installs: 0, stars: 0, versions: 1 }, + })), + normalizeId: vi.fn(), + insert: vi.fn(), + patch, + replace: vi.fn(), + delete: vi.fn(), + system: { + get: vi.fn(), + query: vi.fn(), + }, + }, + scheduler: { + runAfter, + }, + }; + + const result = await processStatsHandler(ctx, { batchSize: 500 }); + + expect(take).toHaveBeenCalledWith(100); + expect(result).toEqual({ processed: 100, packagesUpdated: 100 }); + expect(runAfter).toHaveBeenCalledWith(0, expect.anything(), { batchSize: 100 }); + expect(patch).toHaveBeenCalledTimes(200); + }); + + it("prunes processed package stat events older than the cutoff", async () => { + const staleEvents = [ + { _id: "packageStatEvents:old-1", processedAt: 1_000 }, + { _id: "packageStatEvents:old-2", processedAt: 2_000 }, + ]; + const take = vi.fn(async () => staleEvents); + const deleteDoc = vi.fn(); + const gt = vi.fn(() => ({ lt: vi.fn(() => "processed-range") })); + const ctx = { + db: { + query: vi.fn((tableName: string) => { + expect(tableName).toBe("packageStatEvents"); + return { + withIndex: vi.fn((indexName: string, builder: (q: { gt: typeof gt }) => unknown) => { + expect(indexName).toBe("by_unprocessed"); + expect(builder({ gt })).toBe("processed-range"); + return { take }; + }), + }; + }), + delete: deleteDoc, + get: vi.fn(), + insert: vi.fn(), + normalizeId: vi.fn(), + patch: vi.fn(), + replace: vi.fn(), + system: { + get: vi.fn(), + query: vi.fn(), + }, + }, + }; + + const result = await pruneProcessedPackageStatEventBatchHandler(ctx, { + cutoffProcessedAt: 3_000, + dryRun: false, + batchSize: 2, + confirmationToken: "PRUNE_PROCESSED_PACKAGE_STAT_EVENTS", + }); + + expect(gt).toHaveBeenCalledWith("processedAt", 0); + expect(take).toHaveBeenCalledWith(2); + expect(deleteDoc).toHaveBeenCalledWith("packageStatEvents:old-1"); + expect(deleteDoc).toHaveBeenCalledWith("packageStatEvents:old-2"); + expect(result).toEqual({ + cutoffProcessedAt: 3_000, + dryRun: false, + matched: 2, + deleted: 2, + hasMore: true, + }); + }); + + it("builds package activity from 30 daily package stat rows", async () => { + const now = Date.UTC(2026, 6, 18) + 1; + const { startDay, endDay } = getActivityTrendRange(now); + setPackageDailyStatsRolloutAt("2026-06-18T00:00:00.000Z"); + vi.useFakeTimers(); + vi.setSystemTime(now); + + try { + const packageIndexBuilder = { eq: vi.fn(() => packageIndexBuilder) }; + const dailyIndexBuilder = { + eq: vi.fn(() => dailyIndexBuilder), + gte: vi.fn(() => dailyIndexBuilder), + lte: vi.fn(() => dailyIndexBuilder), + }; + const packageWithIndex = vi.fn( + (_indexName: string, buildQuery: (q: typeof packageIndexBuilder) => unknown) => { + buildQuery(packageIndexBuilder); + return { + unique: vi.fn(async () => ({ + _id: "packages:one", + _creationTime: Date.UTC(2026, 5, 1), + createdAt: Date.UTC(2026, 5, 1), + normalizedName: "demo-plugin", + channel: "public", + scanStatus: "clean", + stats: { downloads: 143, installs: 0, stars: 0, versions: 1 }, + })), + }; + }, + ); + const takeDailyStats = vi.fn(async () => [ + { day: endDay - 1, downloads: 2, installs: 1 }, + { day: endDay, downloads: 1, installs: 3 }, + ]); + const dailyWithIndex = vi.fn( + (_indexName: string, buildQuery: (q: typeof dailyIndexBuilder) => unknown) => { + buildQuery(dailyIndexBuilder); + return { take: takeDailyStats }; + }, + ); + const ctx = { + db: { + query: vi.fn((tableName: string) => { + if (tableName === "packages") return { withIndex: packageWithIndex }; + if (tableName === "packageDailyStats") return { withIndex: dailyWithIndex }; + throw new Error(`Unexpected table ${tableName}`); + }), + get: vi.fn(), + normalizeId: vi.fn(), + insert: vi.fn(), + patch: vi.fn(), + replace: vi.fn(), + delete: vi.fn(), + system: { + get: vi.fn(), + query: vi.fn(), + }, + }, + }; + + const trend = await getActivityTrendHandler(ctx, { name: "demo-plugin", endDay }); + + expect(packageWithIndex).toHaveBeenCalledWith("by_name", expect.any(Function)); + expect(packageIndexBuilder.eq).toHaveBeenCalledWith("normalizedName", "demo-plugin"); + expect(dailyWithIndex).toHaveBeenCalledWith("by_package_day", expect.any(Function)); + expect(dailyIndexBuilder.eq).toHaveBeenCalledWith("packageId", "packages:one"); + expect(dailyIndexBuilder.gte).toHaveBeenCalledWith("day", startDay); + expect(dailyIndexBuilder.lte).toHaveBeenCalledWith("day", endDay); + expect(takeDailyStats).toHaveBeenCalledWith(ACTIVITY_TREND_DAYS); + expect(trend?.downloads.range).toBe("daily"); + expect(trend?.downloads.days).toBe(ACTIVITY_TREND_DAYS); + expect(trend?.downloads.total).toBe(3); + expect(trend?.downloads.points).toHaveLength(ACTIVITY_TREND_DAYS); + expect(trend?.downloads.points[0]).toEqual({ day: startDay, value: 0 }); + expect(trend?.downloads.points.at(-1)).toEqual({ day: endDay, value: 1 }); + expect(trend?.downloads.points.find((point) => point.day === endDay - 1)).toEqual({ + day: endDay - 1, + value: 2, + }); + expect(trend && "installs" in trend).toBe(false); + } finally { + vi.useRealTimers(); + } + }); + + it("returns null for unseeded historical package trends", async () => { + const now = Date.UTC(2026, 5, 20) + 1; + vi.useFakeTimers(); + vi.setSystemTime(now); + + try { + const packageIndexBuilder = { eq: vi.fn(() => packageIndexBuilder) }; + const dailyIndexBuilder = { + eq: vi.fn(() => dailyIndexBuilder), + gte: vi.fn(() => dailyIndexBuilder), + lte: vi.fn(() => dailyIndexBuilder), + }; + const packageWithIndex = vi.fn( + (_indexName: string, buildQuery: (q: typeof packageIndexBuilder) => unknown) => { + buildQuery(packageIndexBuilder); + return { + unique: vi.fn(async () => ({ + _id: "packages:one", + _creationTime: Date.UTC(2026, 5, 1), + createdAt: Date.UTC(2026, 5, 1), + normalizedName: "demo-plugin", + channel: "public", + scanStatus: "clean", + stats: { downloads: 143, installs: 12, stars: 0, versions: 1 }, + })), + }; + }, + ); + const takeDailyStats = vi.fn(async () => []); + const dailyWithIndex = vi.fn( + (_indexName: string, buildQuery: (q: typeof dailyIndexBuilder) => unknown) => { + buildQuery(dailyIndexBuilder); + return { take: takeDailyStats }; + }, + ); + const ctx = { + db: { + query: vi.fn((tableName: string) => { + if (tableName === "packages") return { withIndex: packageWithIndex }; + if (tableName === "packageDailyStats") return { withIndex: dailyWithIndex }; + throw new Error(`Unexpected table ${tableName}`); + }), + get: vi.fn(), + normalizeId: vi.fn(), + insert: vi.fn(), + patch: vi.fn(), + replace: vi.fn(), + delete: vi.fn(), + system: { + get: vi.fn(), + query: vi.fn(), + }, + }, + }; + + const { endDay } = getActivityTrendRange(now); + const trend = await getActivityTrendHandler(ctx, { name: "demo-plugin", endDay }); + + expect(takeDailyStats).toHaveBeenCalledWith(ACTIVITY_TREND_DAYS); + expect(trend).toBeNull(); + } finally { + vi.useRealTimers(); + } + }); + + it("uses package activity rows without a rollout env when they cover all-time totals", async () => { + const now = Date.UTC(2026, 5, 20) + 1; + const { startDay, endDay } = getActivityTrendRange(now); + setPackageDailyStatsRolloutAt(undefined); + vi.useFakeTimers(); + vi.setSystemTime(now); + + try { + const packageIndexBuilder = { eq: vi.fn(() => packageIndexBuilder) }; + const dailyIndexBuilder = { + eq: vi.fn(() => dailyIndexBuilder), + gte: vi.fn(() => dailyIndexBuilder), + lte: vi.fn(() => dailyIndexBuilder), + }; + const packageWithIndex = vi.fn( + (_indexName: string, buildQuery: (q: typeof packageIndexBuilder) => unknown) => { + buildQuery(packageIndexBuilder); + return { + unique: vi.fn(async () => ({ + _id: "packages:one", + _creationTime: Date.UTC(2026, 4, 1), + createdAt: Date.UTC(2026, 4, 1), + normalizedName: "demo-plugin", + channel: "public", + scanStatus: "clean", + stats: { downloads: 143, installs: 12, stars: 0, versions: 1 }, + })), + }; + }, + ); + const takeDailyStats = vi.fn(async () => [ + { day: endDay - 2, downloads: 41, installs: 3 }, + { day: endDay - 1, downloads: 52, installs: 4 }, + { day: endDay, downloads: 50, installs: 5 }, + ]); + const dailyWithIndex = vi.fn( + (_indexName: string, buildQuery: (q: typeof dailyIndexBuilder) => unknown) => { + buildQuery(dailyIndexBuilder); + return { take: takeDailyStats }; + }, + ); + const ctx = { + db: { + query: vi.fn((tableName: string) => { + if (tableName === "packages") return { withIndex: packageWithIndex }; + if (tableName === "packageDailyStats") return { withIndex: dailyWithIndex }; + throw new Error(`Unexpected table ${tableName}`); + }), + get: vi.fn(), + normalizeId: vi.fn(), + insert: vi.fn(), + patch: vi.fn(), + replace: vi.fn(), + delete: vi.fn(), + system: { + get: vi.fn(), + query: vi.fn(), + }, + }, + }; + + const trend = await getActivityTrendHandler(ctx, { name: "demo-plugin", endDay }); + + expect(takeDailyStats).toHaveBeenCalledWith(ACTIVITY_TREND_DAYS); + expect(trend?.downloads.total).toBe(143); + expect(trend?.downloads.points).toHaveLength(ACTIVITY_TREND_DAYS); + expect(trend?.downloads.points[0]).toEqual({ day: startDay, value: 0 }); + expect(trend?.downloads.points.at(-1)).toEqual({ day: endDay, value: 50 }); + } finally { + vi.useRealTimers(); + } + }); + + it("returns null until the actual package daily rollout window is complete", async () => { + const now = Date.UTC(2026, 6, 18) + 1; + const { endDay } = getActivityTrendRange(now); + setPackageDailyStatsRolloutAt("2026-07-10T00:00:00.000Z"); + vi.useFakeTimers(); + vi.setSystemTime(now); + + try { + const packageIndexBuilder = { eq: vi.fn(() => packageIndexBuilder) }; + const dailyIndexBuilder = { + eq: vi.fn(() => dailyIndexBuilder), + gte: vi.fn(() => dailyIndexBuilder), + lte: vi.fn(() => dailyIndexBuilder), + }; + const packageWithIndex = vi.fn( + (_indexName: string, buildQuery: (q: typeof packageIndexBuilder) => unknown) => { + buildQuery(packageIndexBuilder); + return { + unique: vi.fn(async () => ({ + _id: "packages:one", + _creationTime: Date.UTC(2026, 5, 1), + createdAt: Date.UTC(2026, 5, 1), + normalizedName: "demo-plugin", + channel: "public", + scanStatus: "clean", + stats: { downloads: 143, installs: 12, stars: 0, versions: 1 }, + })), + }; + }, + ); + const takeDailyStats = vi.fn(async () => [{ day: endDay, downloads: 1, installs: 1 }]); + const dailyWithIndex = vi.fn( + (_indexName: string, buildQuery: (q: typeof dailyIndexBuilder) => unknown) => { + buildQuery(dailyIndexBuilder); + return { take: takeDailyStats }; + }, + ); + const ctx = { + db: { + query: vi.fn((tableName: string) => { + if (tableName === "packages") return { withIndex: packageWithIndex }; + if (tableName === "packageDailyStats") return { withIndex: dailyWithIndex }; + throw new Error(`Unexpected table ${tableName}`); + }), + get: vi.fn(), + normalizeId: vi.fn(), + insert: vi.fn(), + patch: vi.fn(), + replace: vi.fn(), + delete: vi.fn(), + system: { + get: vi.fn(), + query: vi.fn(), + }, + }, + }; + + const trend = await getActivityTrendHandler(ctx, { name: "demo-plugin", endDay }); + + expect(takeDailyStats).toHaveBeenCalledWith(ACTIVITY_TREND_DAYS); + expect(trend).toBeNull(); + } finally { + vi.useRealTimers(); + } + }); + + it("clamps future package trend windows before checking rollout completeness", async () => { + const now = Date.UTC(2026, 6, 18) + 1; + const { endDay: serverEndDay } = getActivityTrendRange(now); + const { endDay: futureEndDay } = getActivityTrendRange(Date.UTC(2026, 8, 20) + 1); + setPackageDailyStatsRolloutAt("2026-08-10T00:00:00.000Z"); + vi.useFakeTimers(); + vi.setSystemTime(now); + + try { + const packageIndexBuilder = { eq: vi.fn(() => packageIndexBuilder) }; + const dailyIndexBuilder = { + eq: vi.fn(() => dailyIndexBuilder), + gte: vi.fn(() => dailyIndexBuilder), + lte: vi.fn(() => dailyIndexBuilder), + }; + const packageWithIndex = vi.fn( + (_indexName: string, buildQuery: (q: typeof packageIndexBuilder) => unknown) => { + buildQuery(packageIndexBuilder); + return { + unique: vi.fn(async () => ({ + _id: "packages:one", + _creationTime: Date.UTC(2026, 5, 1), + createdAt: Date.UTC(2026, 5, 1), + normalizedName: "demo-plugin", + channel: "public", + scanStatus: "clean", + stats: { downloads: 143, installs: 12, stars: 0, versions: 1 }, + })), + }; + }, + ); + const dailyWithIndex = vi.fn( + (_indexName: string, buildQuery: (q: typeof dailyIndexBuilder) => unknown) => { + buildQuery(dailyIndexBuilder); + return { take: vi.fn(async () => [{ day: futureEndDay, downloads: 1, installs: 1 }]) }; + }, + ); + const ctx = { + db: { + query: vi.fn((tableName: string) => { + if (tableName === "packages") return { withIndex: packageWithIndex }; + if (tableName === "packageDailyStats") return { withIndex: dailyWithIndex }; + throw new Error(`Unexpected table ${tableName}`); + }), + get: vi.fn(), + normalizeId: vi.fn(), + insert: vi.fn(), + patch: vi.fn(), + replace: vi.fn(), + delete: vi.fn(), + system: { + get: vi.fn(), + query: vi.fn(), + }, + }, + }; + + const trend = await getActivityTrendHandler(ctx, { + name: "demo-plugin", + endDay: futureEndDay, + }); + + expect(dailyIndexBuilder.lte).toHaveBeenCalledWith("day", serverEndDay); + expect(trend).toBeNull(); + } finally { + vi.useRealTimers(); + } + }); + + it("returns private package trends when the owner is signed in", async () => { + const now = Date.UTC(2026, 6, 18) + 1; + const { endDay } = getActivityTrendRange(now); + setPackageDailyStatsRolloutAt("2026-06-18T00:00:00.000Z"); + vi.mocked(getAuthUserId).mockResolvedValue("users:owner" as never); + vi.useFakeTimers(); + vi.setSystemTime(now); + + try { + const packageIndexBuilder = { eq: vi.fn(() => packageIndexBuilder) }; + const dailyIndexBuilder = { + eq: vi.fn(() => dailyIndexBuilder), + gte: vi.fn(() => dailyIndexBuilder), + lte: vi.fn(() => dailyIndexBuilder), + }; + const packageWithIndex = vi.fn( + (_indexName: string, buildQuery: (q: typeof packageIndexBuilder) => unknown) => { + buildQuery(packageIndexBuilder); + return { + unique: vi.fn(async () => ({ + _id: "packages:one", + _creationTime: Date.UTC(2026, 5, 1), + createdAt: Date.UTC(2026, 5, 1), + ownerUserId: "users:owner", + ownerPublisherId: undefined, + normalizedName: "demo-plugin", + channel: "private", + scanStatus: "clean", + stats: { downloads: 143, installs: 12, stars: 0, versions: 1 }, + })), + }; + }, + ); + const takeDailyStats = vi.fn(async () => [{ day: endDay, downloads: 2, installs: 1 }]); + const dailyWithIndex = vi.fn( + (_indexName: string, buildQuery: (q: typeof dailyIndexBuilder) => unknown) => { + buildQuery(dailyIndexBuilder); + return { take: takeDailyStats }; + }, + ); + const get = vi.fn(async (id: string) => { + if (id === "users:owner") { + return { _id: "users:owner", deletedAt: undefined, deactivatedAt: undefined }; + } + return null; + }); + const ctx = { + db: { + query: vi.fn((tableName: string) => { + if (tableName === "packages") return { withIndex: packageWithIndex }; + if (tableName === "packageDailyStats") return { withIndex: dailyWithIndex }; + throw new Error(`Unexpected table ${tableName}`); + }), + get, + normalizeId: vi.fn(), + insert: vi.fn(), + patch: vi.fn(), + replace: vi.fn(), + delete: vi.fn(), + system: { + get: vi.fn(), + query: vi.fn(), + }, + }, + }; + + const trend = await getActivityTrendHandler(ctx, { name: "demo-plugin", endDay }); + + expect(getAuthUserId).toHaveBeenCalled(); + expect(get).toHaveBeenCalledWith("users:owner"); + expect(trend?.downloads.total).toBe(2); + } finally { + vi.useRealTimers(); + } + }); }); diff --git a/convex/packages.ts b/convex/packages.ts index bfbcdd7f..048e82f3 100644 --- a/convex/packages.ts +++ b/convex/packages.ts @@ -53,10 +53,18 @@ import { appendPackageModerationEventLog, } from "./lib/artifactModeration"; import { sha256Hex } from "./lib/clawpack"; +import { + ACTIVITY_TREND_DAYS, + ACTIVITY_TREND_DAY_MS, + buildDailyMetricTrends, + clampActivityTrendEndDay, + getActivityTrendRangeForEndDay, +} from "./lib/downloadTrend"; import { buildPackageInspectorFindingsEmail } from "./lib/emails"; import { requireGitHubAccountAge } from "./lib/githubAccount"; import { normalizeGitHubRepository } from "./lib/githubActionsOidc"; import { readGlobalPublicPluginsCount } from "./lib/globalStats"; +import { toDayKey } from "./lib/leaderboards"; import { isOfficialPublisher } from "./lib/officialPublishers"; import { getPackageReleaseArtifactSha256 } from "./lib/packageArtifacts"; import { @@ -310,6 +318,75 @@ const skillSpectorAnalysisValidator = v.object({ checkedAt: v.number(), }); +const PACKAGE_DAILY_STATS_ROLLOUT_AT_ENV = "PACKAGE_DAILY_STATS_ROLLOUT_AT"; +const PACKAGE_STAT_EVENT_BATCH_SIZE = 100; +export const PROCESSED_PACKAGE_STAT_EVENT_PRUNE_CONFIRMATION_TOKEN = + "PRUNE_PROCESSED_PACKAGE_STAT_EVENTS"; +const DEFAULT_PROCESSED_PACKAGE_STAT_EVENT_RETENTION_DAYS = 7; +const MIN_PROCESSED_PACKAGE_STAT_EVENT_RETENTION_DAYS = 1; +const MAX_PROCESSED_PACKAGE_STAT_EVENT_RETENTION_DAYS = 90; +const DEFAULT_PROCESSED_PACKAGE_STAT_EVENT_PRUNE_BATCH_SIZE = 1_000; +const MAX_PROCESSED_PACKAGE_STAT_EVENT_PRUNE_BATCH_SIZE = 5_000; +const DEFAULT_PROCESSED_PACKAGE_STAT_EVENT_PRUNE_MAX_BATCHES = 20; +const MAX_PROCESSED_PACKAGE_STAT_EVENT_PRUNE_MAX_BATCHES = 100; + +type ProcessedPackageStatEventPruneBatchResult = { + cutoffProcessedAt: number; + dryRun: boolean; + matched: number; + deleted: number; + hasMore: boolean; +}; + +type ProcessedPackageStatEventPruneResult = { + cutoffProcessedAt: number; + retentionDays: number; + dryRun: boolean; + batches: number; + matched: number; + deleted: number; + stoppedReason: "empty" | "max_batches"; + scheduledContinuation: boolean; +}; + +function clampPackageStatInt(value: number, min: number, max: number) { + if (!Number.isFinite(value)) return min; + return Math.max(min, Math.min(Math.floor(value), max)); +} + +function normalizeProcessedPackageStatEventRetentionDays(retentionDays: number | undefined) { + return clampPackageStatInt( + retentionDays ?? DEFAULT_PROCESSED_PACKAGE_STAT_EVENT_RETENTION_DAYS, + MIN_PROCESSED_PACKAGE_STAT_EVENT_RETENTION_DAYS, + MAX_PROCESSED_PACKAGE_STAT_EVENT_RETENTION_DAYS, + ); +} + +function normalizeProcessedPackageStatEventPruneBatchSize(batchSize: number | undefined) { + return clampPackageStatInt( + batchSize ?? DEFAULT_PROCESSED_PACKAGE_STAT_EVENT_PRUNE_BATCH_SIZE, + 1, + MAX_PROCESSED_PACKAGE_STAT_EVENT_PRUNE_BATCH_SIZE, + ); +} + +function normalizeProcessedPackageStatEventPruneMaxBatches(maxBatches: number | undefined) { + return clampPackageStatInt( + maxBatches ?? DEFAULT_PROCESSED_PACKAGE_STAT_EVENT_PRUNE_MAX_BATCHES, + 1, + MAX_PROCESSED_PACKAGE_STAT_EVENT_PRUNE_MAX_BATCHES, + ); +} + +function getPackageDailyStatsRolloutTime() { + const raw = process.env[PACKAGE_DAILY_STATS_ROLLOUT_AT_ENV]?.trim(); + if (!raw) return null; + + const numeric = Number(raw); + const parsed = Number.isFinite(numeric) ? numeric : Date.parse(raw); + return Number.isFinite(parsed) && parsed > 0 ? parsed : null; +} + function inferOwnerHandleFromScopedPackageName(name: string) { const match = /^@([^/]+)\//.exec(name); return match?.[1] || undefined; @@ -1779,6 +1856,29 @@ function buildPackagePluginCategoryDigestQuery( const channel = args.channel; const isOfficial = args.isOfficial; if (args.sort === "downloads") { + if (family && channel && typeof isOfficial === "boolean") { + return ctx.db + .query("packagePluginCategorySearchDigest") + .withIndex("by_active_family_channel_official_category_downloads", (q) => + q + .eq("softDeletedAt", undefined) + .eq("family", family) + .eq("channel", channel) + .eq("isOfficial", isOfficial) + .eq("pluginCategory", args.category), + ); + } + if (family && channel) { + return ctx.db + .query("packagePluginCategorySearchDigest") + .withIndex("by_active_family_channel_category_downloads", (q) => + q + .eq("softDeletedAt", undefined) + .eq("family", family) + .eq("channel", channel) + .eq("pluginCategory", args.category), + ); + } if (family && typeof isOfficial === "boolean") { return ctx.db .query("packagePluginCategorySearchDigest") @@ -1790,6 +1890,17 @@ function buildPackagePluginCategoryDigestQuery( .eq("pluginCategory", args.category), ); } + if (channel && typeof isOfficial === "boolean") { + return ctx.db + .query("packagePluginCategorySearchDigest") + .withIndex("by_active_channel_official_category_downloads", (q) => + q + .eq("softDeletedAt", undefined) + .eq("channel", channel) + .eq("isOfficial", isOfficial) + .eq("pluginCategory", args.category), + ); + } if (family) { return ctx.db .query("packagePluginCategorySearchDigest") @@ -1797,6 +1908,16 @@ function buildPackagePluginCategoryDigestQuery( q.eq("softDeletedAt", undefined).eq("family", family).eq("pluginCategory", args.category), ); } + if (channel) { + return ctx.db + .query("packagePluginCategorySearchDigest") + .withIndex("by_active_channel_category_downloads", (q) => + q + .eq("softDeletedAt", undefined) + .eq("channel", channel) + .eq("pluginCategory", args.category), + ); + } if (typeof isOfficial === "boolean") { return ctx.db .query("packagePluginCategorySearchDigest") @@ -1961,6 +2082,65 @@ function buildPackageTopicDigestQuery( const channel = args.channel; const isOfficial = args.isOfficial; if (args.sort === "downloads") { + if (family && channel && typeof isOfficial === "boolean") { + return ctx.db + .query("packageTopicSearchDigest") + .withIndex("by_active_family_channel_official_topic_downloads", (q) => + q + .eq("softDeletedAt", undefined) + .eq("family", family) + .eq("channel", channel) + .eq("isOfficial", isOfficial) + .eq("topic", args.topic), + ); + } + if (family && channel) { + return ctx.db + .query("packageTopicSearchDigest") + .withIndex("by_active_family_channel_topic_downloads", (q) => + q + .eq("softDeletedAt", undefined) + .eq("family", family) + .eq("channel", channel) + .eq("topic", args.topic), + ); + } + if (family && typeof isOfficial === "boolean") { + return ctx.db + .query("packageTopicSearchDigest") + .withIndex("by_active_family_official_topic_downloads", (q) => + q + .eq("softDeletedAt", undefined) + .eq("family", family) + .eq("isOfficial", isOfficial) + .eq("topic", args.topic), + ); + } + if (channel && typeof isOfficial === "boolean") { + return ctx.db + .query("packageTopicSearchDigest") + .withIndex("by_active_channel_official_topic_downloads", (q) => + q + .eq("softDeletedAt", undefined) + .eq("channel", channel) + .eq("isOfficial", isOfficial) + .eq("topic", args.topic), + ); + } + if (family) { + return ctx.db + .query("packageTopicSearchDigest") + .withIndex("by_active_family_topic_downloads", (q) => + q.eq("softDeletedAt", undefined).eq("family", family).eq("topic", args.topic), + ); + } + if (channel) { + return ctx.db + .query("packageTopicSearchDigest") + .withIndex("by_active_channel_topic_downloads", (q) => + q.eq("softDeletedAt", undefined).eq("channel", channel).eq("topic", args.topic), + ); + } if (typeof isOfficial === "boolean") { return ctx.db .query("packageTopicSearchDigest") @@ -2743,7 +2923,7 @@ export const listAuditPage = query({ const numItems = Math.max(1, Math.min(args.paginationOpts.numItems, MAX_PUBLIC_LIST_PAGE_SIZE)); const result = await ctx.db .query("packages") - .withIndex("by_active_installs", (q) => q.eq("softDeletedAt", undefined)) + .withIndex("by_active_downloads", (q) => q.eq("softDeletedAt", undefined)) .order("desc") .paginate({ cursor: args.paginationOpts.cursor, numItems }); @@ -3822,6 +4002,54 @@ export const getPackageByNameInternal = internalQuery({ }, }); +async function buildPackageActivityTrend(ctx: DbReaderCtx, pkg: Doc<"packages">, endDay: number) { + const safeEndDay = clampActivityTrendEndDay(endDay, Date.now()); + const { startDay, endDay: normalizedEndDay } = getActivityTrendRangeForEndDay(safeEndDay); + const rows = await ctx.db + .query("packageDailyStats") + .withIndex("by_package_day", (q) => + q.eq("packageId", pkg._id).gte("day", startDay).lte("day", normalizedEndDay), + ) + .take(ACTIVITY_TREND_DAYS); + + const allTimeDownloads = Math.max(0, Math.trunc(pkg.stats?.downloads ?? 0)); + const allTimeInstalls = Math.max(0, Math.trunc(pkg.stats?.installs ?? 0)); + const dailyTotals = rows.reduce( + (totals, row) => ({ + downloads: totals.downloads + Math.max(0, Math.trunc(row.downloads)), + installs: totals.installs + Math.max(0, Math.trunc(row.installs)), + }), + { downloads: 0, installs: 0 }, + ); + const dailyRowsCoverAllTimeActivity = + dailyTotals.downloads >= allTimeDownloads && dailyTotals.installs >= allTimeInstalls; + const packageDailyStatsRolloutTime = getPackageDailyStatsRolloutTime(); + const hasAllTimeActivity = allTimeDownloads > 0 || allTimeInstalls > 0; + const packageCreatedAt = pkg.createdAt ?? pkg._creationTime; + const hasUntrustedHistoricalActivity = + hasAllTimeActivity && + (packageDailyStatsRolloutTime === null || packageCreatedAt < packageDailyStatsRolloutTime); + const hasCompleteDailyWindow = + packageDailyStatsRolloutTime !== null && + startDay * ACTIVITY_TREND_DAY_MS >= packageDailyStatsRolloutTime; + if (hasUntrustedHistoricalActivity && !hasCompleteDailyWindow && !dailyRowsCoverAllTimeActivity) { + return null; + } + + return buildDailyMetricTrends(rows, normalizedEndDay); +} + +export const getActivityTrendForName = query({ + args: { name: v.string(), endDay: v.number() }, + handler: async (ctx, args) => { + const viewerUserId = await getOptionalViewerUserId(ctx); + const pkg = await getReadablePackageByName(ctx, args.name, viewerUserId); + if (!pkg) return null; + + return await buildPackageActivityTrend(ctx, pkg, args.endDay); + }, +}); + export const recordPackageDownloadInternal = internalMutation({ args: { packageId: v.id("packages") }, handler: async (ctx, args) => { @@ -3881,10 +4109,55 @@ export const recordPackageInstallInternal = internalMutation({ }, }); +async function bumpDailyPackageStats( + ctx: MutationCtx, + params: { + packageId: Id<"packages">; + day: number; + downloads: number; + installs: number; + now: number; + }, +) { + if (params.downloads === 0 && params.installs === 0) return; + + const existing = await ctx.db + .query("packageDailyStats") + .withIndex("by_package_day", (q) => q.eq("packageId", params.packageId).eq("day", params.day)) + .unique(); + + if (existing) { + await ctx.db.patch(existing._id, { + downloads: Math.max(0, existing.downloads + params.downloads), + installs: Math.max(0, existing.installs + params.installs), + updatedAt: params.now, + }); + return; + } + + await ctx.db.insert("packageDailyStats", { + packageId: params.packageId, + day: params.day, + downloads: Math.max(0, params.downloads), + installs: Math.max(0, params.installs), + updatedAt: params.now, + }); +} + +type PackageDailyStatsDelta = { + packageId: Id<"packages">; + day: number; + downloads: number; + installs: number; +}; + export const processPackageStatEventsInternal = internalMutation({ args: { batchSize: v.optional(v.number()) }, handler: async (ctx, args) => { - const batchSize = Math.max(1, Math.min(args.batchSize ?? 500, 1_000)); + const batchSize = Math.max( + 1, + Math.min(args.batchSize ?? PACKAGE_STAT_EVENT_BATCH_SIZE, PACKAGE_STAT_EVENT_BATCH_SIZE), + ); const now = Date.now(); const events = await ctx.db .query("packageStatEvents") @@ -3894,12 +4167,34 @@ export const processPackageStatEventsInternal = internalMutation({ if (events.length === 0) return { processed: 0, packagesUpdated: 0 }; const statsByPackage = new Map, { downloads: number; installs: number }>(); + const dailyStatsByPackageDay = new Map(); + const dailyStatsByPackage = new Map, PackageDailyStatsDelta[]>(); for (const event of events) { const stats = statsByPackage.get(event.packageId) ?? { downloads: 0, installs: 0 }; + const day = toDayKey(event.occurredAt); + const dailyKey = `${event.packageId}:${day}`; + let dailyStats = dailyStatsByPackageDay.get(dailyKey); + if (!dailyStats) { + dailyStats = { + packageId: event.packageId, + day, + downloads: 0, + installs: 0, + }; + dailyStatsByPackageDay.set(dailyKey, dailyStats); + const packageDailyStats = dailyStatsByPackage.get(event.packageId); + if (packageDailyStats) { + packageDailyStats.push(dailyStats); + } else { + dailyStatsByPackage.set(event.packageId, [dailyStats]); + } + } if (event.kind === "install") { stats.installs += 1; + dailyStats.installs += 1; } else { stats.downloads += 1; + dailyStats.downloads += 1; } statsByPackage.set(event.packageId, stats); } @@ -3908,6 +4203,9 @@ export const processPackageStatEventsInternal = internalMutation({ for (const [packageId, stats] of statsByPackage) { const pkg = await ctx.db.get(packageId); if (!pkg) continue; + for (const dailyStats of dailyStatsByPackage.get(packageId) ?? []) { + await bumpDailyPackageStats(ctx, { ...dailyStats, now }); + } const nextStats = { downloads: (pkg.stats?.downloads ?? 0) + stats.downloads, installs: (pkg.stats?.installs ?? 0) + stats.installs, @@ -3935,6 +4233,117 @@ export const processPackageStatEventsInternal = internalMutation({ }, }); +export const pruneProcessedPackageStatEventBatchInternal = internalMutation({ + args: { + cutoffProcessedAt: v.number(), + dryRun: v.boolean(), + batchSize: v.optional(v.number()), + confirmationToken: v.optional(v.string()), + }, + handler: async (ctx, args): Promise => { + if ( + !args.dryRun && + args.confirmationToken !== PROCESSED_PACKAGE_STAT_EVENT_PRUNE_CONFIRMATION_TOKEN + ) { + throw new Error( + `Apply requires confirmationToken=${PROCESSED_PACKAGE_STAT_EVENT_PRUNE_CONFIRMATION_TOKEN}`, + ); + } + + const batchSize = normalizeProcessedPackageStatEventPruneBatchSize(args.batchSize); + const events = await ctx.db + .query("packageStatEvents") + .withIndex("by_unprocessed", (q) => + q.gt("processedAt", 0).lt("processedAt", args.cutoffProcessedAt), + ) + .take(batchSize); + + if (!args.dryRun) { + for (const event of events) { + await ctx.db.delete(event._id); + } + } + + return { + cutoffProcessedAt: args.cutoffProcessedAt, + dryRun: args.dryRun, + matched: events.length, + deleted: args.dryRun ? 0 : events.length, + hasMore: events.length === batchSize, + }; + }, +}); + +export const pruneProcessedPackageStatEventsInternal: ReturnType = + internalAction({ + args: { + dryRun: v.optional(v.boolean()), + retentionDays: v.optional(v.number()), + batchSize: v.optional(v.number()), + maxBatches: v.optional(v.number()), + confirmationToken: v.optional(v.string()), + }, + handler: async (ctx, args): Promise => { + const dryRun = args.dryRun ?? false; + const retentionDays = normalizeProcessedPackageStatEventRetentionDays(args.retentionDays); + const batchSize = normalizeProcessedPackageStatEventPruneBatchSize(args.batchSize); + const maxBatches = normalizeProcessedPackageStatEventPruneMaxBatches(args.maxBatches); + const cutoffProcessedAt = Date.now() - retentionDays * 24 * 60 * 60 * 1_000; + + let batches = 0; + let matched = 0; + let deleted = 0; + let hasMore = false; + let stoppedReason: "empty" | "max_batches" = "empty"; + const batchLimit = dryRun ? 1 : maxBatches; + + for (let index = 0; index < batchLimit; index += 1) { + const batch = (await ctx.runMutation( + internal.packages.pruneProcessedPackageStatEventBatchInternal, + { + cutoffProcessedAt, + dryRun, + batchSize, + confirmationToken: args.confirmationToken, + }, + )) as ProcessedPackageStatEventPruneBatchResult; + + batches += 1; + matched += batch.matched; + deleted += batch.deleted; + hasMore = batch.hasMore; + + if (!batch.hasMore) { + stoppedReason = "empty"; + break; + } + + stoppedReason = "max_batches"; + } + + if (!dryRun && hasMore && stoppedReason === "max_batches") { + await ctx.scheduler.runAfter(0, internal.packages.pruneProcessedPackageStatEventsInternal, { + dryRun, + retentionDays, + batchSize, + maxBatches, + confirmationToken: args.confirmationToken, + }); + } + + return { + cutoffProcessedAt, + retentionDays, + dryRun, + batches, + matched, + deleted, + stoppedReason, + scheduledContinuation: !dryRun && hasMore && stoppedReason === "max_batches", + }; + }, + }); + export const getTrustedPublisherByPackageIdInternal = internalQuery({ args: { packageId: v.id("packages") }, handler: async (ctx, args) => { @@ -4255,6 +4664,12 @@ async function hardDeletePackageDoc( .collect(); for (const statEvent of statEvents) await ctx.db.delete(statEvent._id); + const dailyStats = await ctx.db + .query("packageDailyStats") + .withIndex("by_package_day", (q) => q.eq("packageId", pkg._id)) + .collect(); + for (const dailyStat of dailyStats) await ctx.db.delete(dailyStat._id); + for (const release of releases) await ctx.db.delete(release._id); await ctx.db.delete(pkg._id); await ctx.db.insert("auditLogs", { diff --git a/convex/publishers.test.ts b/convex/publishers.test.ts index e01fb3b0..2b6f827a 100644 --- a/convex/publishers.test.ts +++ b/convex/publishers.test.ts @@ -29,6 +29,7 @@ import { vi.mock("@convex-dev/auth/server", () => ({ getAuthUserId: vi.fn(), + authTables: {}, })); type WrappedHandler = { @@ -177,7 +178,7 @@ const listPublicHandler = ( items: Array<{ handle: string; kind: "user" | "org"; - stats: { installs: number }; + stats: { downloads: number; installs: number }; publishedItems?: Array<{ displayName: string }>; }>; total: number; @@ -198,7 +199,7 @@ const listPublicPageHandler = ( page: Array<{ handle: string; kind: "user" | "org"; - stats: { installs: number }; + stats: { downloads: number; installs: number }; publishedItems: Array<{ displayName: string; installs: number; downloads: number }>; }>; counts: { all: number; individuals: number; organizations: number }; @@ -983,7 +984,7 @@ describe("publishers membership controls", () => { }, ); - it("lists individual and org publishers ranked by aggregate installs", async () => { + it("lists individual and org publishers ranked by aggregate downloads", async () => { const publisherRows = [ { _id: "publishers:alice", @@ -1053,7 +1054,7 @@ describe("publishers membership controls", () => { if (table === "publishers" && indexName === "by_handle") { return { unique: vi.fn(async () => null) }; } - if (table === "publishers" && indexName === "by_active_total_installs") { + if (table === "publishers" && indexName === "by_active_total_downloads") { return { order: vi.fn(() => ({ collect: vi.fn(async () => publisherRows) })), }; @@ -1083,7 +1084,7 @@ describe("publishers membership controls", () => { expect(result.counts).toEqual({ all: 2, individuals: 1, organizations: 1 }); expect(result.items.map((item) => item.handle)).toEqual(["openclaw", "alice"]); expect(result.items.map((item) => item.kind)).toEqual(["org", "user"]); - expect(result.items.map((item) => item.stats.installs)).toEqual([15, 5]); + expect(result.items.map((item) => item.stats.downloads)).toEqual([20, 9]); }); it("filters public publisher listings by kind", async () => { @@ -1137,14 +1138,14 @@ describe("publishers membership controls", () => { if (table === "publishers" && indexName === "by_handle") { return { unique: vi.fn(async () => null) }; } - if (table === "publishers" && indexName === "by_active_total_installs") { + if (table === "publishers" && indexName === "by_active_total_downloads") { return { order: vi.fn(() => ({ collect: vi.fn(async () => publisherRows) })), }; } if ( (table === "skills" || table === "packages") && - indexName === "by_owner_publisher_active_installs" + indexName === "by_owner_publisher_active_downloads" ) { return indexedRows([]); } @@ -1236,7 +1237,7 @@ describe("publishers membership controls", () => { if (table === "publishers" && indexName === "by_handle") { return { unique: vi.fn(async () => null) }; } - if (table === "publishers" && indexName === "by_active_total_installs") { + if (table === "publishers" && indexName === "by_active_total_downloads") { return { order: vi.fn(() => ({ collect: vi.fn(async () => publisherRows) })), }; @@ -1244,7 +1245,7 @@ describe("publishers membership controls", () => { if (table === "officialPublishers" && indexName === "by_publisher") { return { unique: vi.fn(async () => null) }; } - if (table === "skills" && indexName === "by_owner_publisher_active_installs") { + if (table === "skills" && indexName === "by_owner_publisher_active_downloads") { return { order: vi.fn(() => ({ take: vi.fn(async (limit: number) => @@ -1255,7 +1256,7 @@ describe("publishers membership controls", () => { })), }; } - if (table === "packages" && indexName === "by_owner_publisher_active_installs") { + if (table === "packages" && indexName === "by_owner_publisher_active_downloads") { return { order: vi.fn(() => ({ take: vi.fn(async () => []) })), }; @@ -1338,6 +1339,18 @@ describe("publishers membership controls", () => { if (table === "publishers" && indexName === "by_handle") { return { unique: vi.fn(async () => null) }; } + if (table === "publishers" && indexName === "by_active_kind_total_downloads") { + return { + order: vi.fn(() => ({ + collect: vi.fn(async () => + publisherRows.filter((publisher) => publisher.kind === fields.kind), + ), + take: vi.fn(async () => + publisherRows.filter((publisher) => publisher.kind === fields.kind), + ), + })), + }; + } if (table === "publishers" && indexName === "by_active_kind_total_installs") { return { order: vi.fn(() => ({ @@ -1347,6 +1360,14 @@ describe("publishers membership controls", () => { })), }; } + if (table === "publishers" && indexName === "by_active_total_downloads") { + return { + order: vi.fn(() => ({ + collect: vi.fn(async () => publisherRows), + take: vi.fn(async () => publisherRows), + })), + }; + } if (table === "publishers" && indexName === "by_active_total_installs") { return { order: vi.fn(() => ({ @@ -1363,7 +1384,7 @@ describe("publishers membership controls", () => { } if ( (table === "skills" || table === "packages") && - indexName === "by_owner_publisher_active_installs" + indexName === "by_owner_publisher_active_downloads" ) { return indexedRows([]); } @@ -1444,6 +1465,14 @@ describe("publishers membership controls", () => { if (table === "publishers" && indexName === "by_handle") { return { unique: vi.fn(async () => null) }; } + if (table === "publishers" && indexName === "by_active_total_downloads") { + return { + order: vi.fn(() => ({ + collect: vi.fn(async () => publisherRows), + take: vi.fn(async () => publisherRows), + })), + }; + } if (table === "publishers" && indexName === "by_active_total_installs") { return { order: vi.fn(() => ({ @@ -1453,7 +1482,7 @@ describe("publishers membership controls", () => { } if ( (table === "skills" || table === "packages") && - indexName === "by_owner_publisher_active_installs" + indexName === "by_owner_publisher_active_downloads" ) { ownerPublisherQueries.push(String(fields.ownerPublisherId)); return indexedRows([]); @@ -1495,7 +1524,7 @@ describe("publishers membership controls", () => { expect(ownerPublisherQueries).toEqual(["publishers:alice", "publishers:alice"]); }); - it("orders and renders public publisher card previews by installs", async () => { + it("orders and renders public publisher card previews by downloads", async () => { const publisherRows = [ { _id: "publishers:openclaw", @@ -1563,20 +1592,19 @@ describe("publishers membership controls", () => { updatedAt: 3, }, ]; - const rowsByInstalls = < + const rowsByDownloads = < T extends { updatedAt: number; - stats?: { installs?: number; installsAllTime?: number }; - statsInstallsAllTime?: number; + stats?: { downloads?: number }; + statsDownloads?: number; }, >( rows: T[], ) => [...rows].sort( (a, b) => - (b.statsInstallsAllTime ?? b.stats?.installs ?? b.stats?.installsAllTime ?? 0) - - (a.statsInstallsAllTime ?? a.stats?.installs ?? a.stats?.installsAllTime ?? 0) || - b.updatedAt - a.updatedAt, + (b.statsDownloads ?? b.stats?.downloads ?? 0) - + (a.statsDownloads ?? a.stats?.downloads ?? 0) || b.updatedAt - a.updatedAt, ); const ctx = { db: { @@ -1594,6 +1622,14 @@ describe("publishers membership controls", () => { if (table === "publishers" && indexName === "by_handle") { return { unique: vi.fn(async () => null) }; } + if (table === "publishers" && indexName === "by_active_total_downloads") { + return { + order: vi.fn(() => ({ + collect: vi.fn(async () => publisherRows), + take: vi.fn(async () => publisherRows), + })), + }; + } if (table === "publishers" && indexName === "by_active_total_installs") { return { order: vi.fn(() => ({ @@ -1604,16 +1640,16 @@ describe("publishers membership controls", () => { if (table === "officialPublishers" && indexName === "by_publisher") { return { unique: vi.fn(async () => null) }; } - if (table === "skills" && indexName === "by_owner_publisher_active_installs") { + if (table === "skills" && indexName === "by_owner_publisher_active_downloads") { return indexedRows( - rowsByInstalls( + rowsByDownloads( skillRows.filter((skill) => skill.ownerPublisherId === fields.ownerPublisherId), ), ); } - if (table === "packages" && indexName === "by_owner_publisher_active_installs") { + if (table === "packages" && indexName === "by_owner_publisher_active_downloads") { return indexedRows( - rowsByInstalls( + rowsByDownloads( packageRows.filter((pkg) => pkg.ownerPublisherId === fields.ownerPublisherId), ), ); @@ -1629,12 +1665,12 @@ describe("publishers membership controls", () => { }); expect(result.page[0]?.publishedItems.map((item) => item.displayName)).toEqual([ - "Recent Plugin", - "Recent Tool", + "Popular Plugin", "Popular Skill", + "Recent Plugin", ]); - expect(result.page[0]?.publishedItems.map((item) => item.installs)).toEqual([50, 40, 35]); - expect(result.page[0]?.publishedItems.map((item) => item.downloads)).toEqual([12, 10, 98]); + expect(result.page[0]?.publishedItems.map((item) => item.installs)).toEqual([5, 35, 50]); + expect(result.page[0]?.publishedItems.map((item) => item.downloads)).toEqual([128, 98, 12]); }); it("does not hydrate every publisher catalog preview before filtering public publisher pages", async () => { @@ -1671,6 +1707,14 @@ describe("publishers membership controls", () => { if (table === "publishers" && indexName === "by_handle") { return { unique: vi.fn(async () => null) }; } + if (table === "publishers" && indexName === "by_active_total_downloads") { + return { + order: vi.fn(() => ({ + collect: vi.fn(async () => publisherRows), + take: vi.fn(async () => publisherRows), + })), + }; + } if (table === "publishers" && indexName === "by_active_total_installs") { return { order: vi.fn(() => ({ @@ -1680,7 +1724,7 @@ describe("publishers membership controls", () => { } if ( (table === "skills" || table === "packages") && - indexName === "by_owner_publisher_active_installs" + indexName === "by_owner_publisher_active_downloads" ) { ownerPublisherQueries.push(String(fields.ownerPublisherId)); return indexedRows([]); @@ -1705,6 +1749,155 @@ describe("publishers membership controls", () => { expect(ownerPublisherQueries).toEqual(["publishers:user-0", "publishers:user-0"]); }); + it("ranks bounded legacy publishers missing download aggregates before paginating", async () => { + const rankedPublisherRows = Array.from({ length: 2 }, (_, index) => ({ + _id: `publishers:user-${index}`, + _creationTime: index, + kind: "user", + handle: `user-${index}`, + displayName: `User ${index}`, + linkedUserId: `users:user-${index}`, + publishedSkills: 1, + publishedPackages: 0, + totalInstalls: 500 - index, + totalDownloads: 500 - index, + totalStars: 1, + createdAt: 1, + updatedAt: 1, + })); + const legacyFillerRows = Array.from({ length: 500 }, (_, index) => ({ + _id: `publishers:legacy-filler-${index}`, + _creationTime: 100 + index, + kind: "user", + handle: `legacy-filler-${index}`, + displayName: `Legacy Filler ${index}`, + linkedUserId: `users:legacy-filler-${index}`, + publishedSkills: 1, + publishedPackages: 0, + totalInstalls: 500 - index, + totalStars: 1, + createdAt: 1, + updatedAt: 1_000 - index, + })); + const legacyPublisher = { + _id: "publishers:legacy-popular", + _creationTime: 600, + kind: "user", + handle: "legacy-popular", + displayName: "Legacy Popular", + linkedUserId: "users:legacy-popular", + publishedSkills: 1, + publishedPackages: 0, + totalInstalls: 1_000, + totalStars: 1, + createdAt: 1, + updatedAt: 1, + }; + const legacyRowsByDownloadsIndex = [...legacyFillerRows, legacyPublisher]; + const legacyRowsByInstallsIndex = [legacyPublisher, ...legacyFillerRows]; + const skillRows = [ + { + _id: "skills:legacy-popular", + ownerPublisherId: "publishers:legacy-popular", + softDeletedAt: undefined, + displayName: "Legacy Popular Skill", + moderationStatus: "active", + statsDownloads: 1000, + statsStars: 1, + statsInstallsAllTime: 1, + stats: { downloads: 1000, stars: 1, installsCurrent: 1, installsAllTime: 1 }, + updatedAt: 1, + }, + ]; + const get = vi.fn(async (id: string) => ({ _id: id, image: `https://github.com/${id}.png` })); + const legacyFallbackCollect = vi.fn(async () => { + throw new Error("legacy publisher fallback must stay bounded"); + }); + const legacyDownloadsFallbackTake = vi.fn(async (limit: number) => + legacyRowsByDownloadsIndex.slice(0, limit), + ); + const legacyInstallsFallbackTake = vi.fn(async (limit: number) => + legacyRowsByInstallsIndex.slice(0, limit), + ); + const ctx = { + db: { + get, + query: vi.fn((table: string) => ({ + withIndex: vi.fn((indexName: string, buildQuery: (q: unknown) => unknown) => { + const fields: Record = {}; + const explicitFields = new Set(); + const q = { + eq: (field: string, value: unknown) => { + fields[field] = value; + explicitFields.add(field); + return q; + }, + }; + buildQuery(q); + if (table === "publishers" && indexName === "by_handle") { + return { unique: vi.fn(async () => null) }; + } + if (table === "publishers" && indexName === "by_active_total_downloads") { + const rows = explicitFields.has("totalDownloads") + ? legacyRowsByDownloadsIndex + : rankedPublisherRows; + return { + order: vi.fn(() => ({ + collect: explicitFields.has("totalDownloads") + ? legacyFallbackCollect + : vi.fn(async () => rows), + take: explicitFields.has("totalDownloads") + ? legacyDownloadsFallbackTake + : vi.fn(async (limit: number) => rows.slice(0, limit)), + })), + }; + } + if (table === "publishers" && indexName === "by_active_total_installs") { + return { + order: vi.fn(() => ({ + collect: legacyFallbackCollect, + take: legacyInstallsFallbackTake, + })), + }; + } + if (table === "skills" && indexName === "by_owner_publisher_active_updated") { + return indexedRows( + skillRows.filter((skill) => skill.ownerPublisherId === fields.ownerPublisherId), + ); + } + if (table === "packages" && indexName === "by_owner_publisher_active_updated") { + return indexedRows([]); + } + if ( + (table === "skills" || table === "packages") && + indexName === "by_owner_publisher_active_downloads" + ) { + return indexedRows( + table === "skills" + ? skillRows.filter((skill) => skill.ownerPublisherId === fields.ownerPublisherId) + : [], + ); + } + if (table === "officialPublishers" && indexName === "by_publisher") { + return { unique: vi.fn(async () => null) }; + } + throw new Error(`unexpected ${table} index ${indexName}`); + }), + })), + }, + }; + + const result = await listPublicPageHandler(ctx as never, { + paginationOpts: { cursor: null, numItems: 1 }, + }); + + expect(result.page.map((item) => item.handle)).toEqual(["legacy-popular"]); + expect(result.page.map((item) => item.stats.downloads)).toEqual([1000]); + expect(legacyDownloadsFallbackTake).not.toHaveBeenCalled(); + expect(legacyInstallsFallbackTake).toHaveBeenCalledWith(500); + expect(legacyFallbackCollect).not.toHaveBeenCalled(); + }); + it("does not hydrate publisher catalog previews when a public publisher search has no matches", async () => { const publisherRows = Array.from({ length: 120 }, (_, index) => ({ _id: `publishers:user-${index}`, @@ -1736,6 +1929,14 @@ describe("publishers membership controls", () => { }, }; buildQuery(q); + if (table === "publishers" && indexName === "by_active_total_downloads") { + return { + order: vi.fn(() => ({ + collect: vi.fn(async () => publisherRows), + take: vi.fn(async () => publisherRows), + })), + }; + } if (table === "publishers" && indexName === "by_active_total_installs") { return { order: vi.fn(() => ({ @@ -1745,7 +1946,7 @@ describe("publishers membership controls", () => { } if ( (table === "skills" || table === "packages") && - indexName === "by_owner_publisher_active_installs" + indexName === "by_owner_publisher_active_downloads" ) { ownerPublisherQueries.push(String(fields.ownerPublisherId)); return indexedRows([]); @@ -1768,7 +1969,7 @@ describe("publishers membership controls", () => { expect(ownerPublisherQueries).toEqual([]); }); - it("normalizes legacy downloads catalog sorts to install-backed profile items", async () => { + it("orders profile catalog items by downloads", async () => { const publisher = { _id: "publishers:openclaw", _creationTime: 1, @@ -1802,14 +2003,25 @@ describe("publishers membership controls", () => { if (table === "packages" && indexName === "by_owner_publisher_active_updated") { return indexedRows([ { - _id: "packages:plugin", + _id: "packages:low-download-plugin", ownerPublisherId: "publishers:openclaw", softDeletedAt: undefined, family: "code-plugin", - name: "@openclaw/example-plugin", - displayName: "Example Plugin", + name: "@openclaw/low-download-plugin", + displayName: "Low Download Plugin", summary: "Scoped plugin", - stats: { downloads: 7, installs: 3, stars: 1, versions: 1 }, + stats: { downloads: 7, installs: 300, stars: 1, versions: 1 }, + updatedAt: 6, + }, + { + _id: "packages:high-download-plugin", + ownerPublisherId: "publishers:openclaw", + softDeletedAt: undefined, + family: "code-plugin", + name: "@openclaw/high-download-plugin", + displayName: "High Download Plugin", + summary: "Scoped plugin", + stats: { downloads: 70, installs: 3, stars: 1, versions: 1 }, updatedAt: 5, }, ]); @@ -1831,11 +2043,17 @@ describe("publishers membership controls", () => { expect(result.page).toMatchObject([ { - displayName: "Example Plugin", - downloads: 7, - href: "/plugins/@openclaw/example-plugin", + displayName: "High Download Plugin", + downloads: 70, + href: "/plugins/@openclaw/high-download-plugin", installs: 3, }, + { + displayName: "Low Download Plugin", + downloads: 7, + href: "/plugins/@openclaw/low-download-plugin", + installs: 300, + }, ]); }); diff --git a/convex/publishers.ts b/convex/publishers.ts index d4c19dfa..276dafa1 100644 --- a/convex/publishers.ts +++ b/convex/publishers.ts @@ -44,6 +44,7 @@ import { readCanonicalStat } from "./lib/skillStats"; import { adjustUserSkillStatsForSkillChange } from "./lib/userSkillStats"; const MAX_PUBLIC_PUBLISHER_LIST_LIMIT = 500; +const LEGACY_PUBLISHER_DOWNLOAD_FALLBACK_LIMIT = MAX_PUBLIC_PUBLISHER_LIST_LIMIT; const PUBLISHER_LIST_PREVIEW_LIMIT = 3; const GITHUB_AUTH_ACCOUNT_RECOVERY_MATCH_LIMIT = 10; const PERSONAL_PUBLISHER_RECOVERY_OWNER_MIGRATION_LIMIT = 100; @@ -100,8 +101,8 @@ type PublisherCatalogItem = { sourceVerifiedCommit?: string | null; }; -type PublisherCatalogSort = "installs" | "recent"; -type PublisherCatalogSortArg = PublisherCatalogSort | "downloads"; +type PublisherCatalogSort = "downloads" | "recent"; +type PublisherCatalogSortArg = PublisherCatalogSort | "installs"; type PublisherListItem = NonNullable> & { stats: PublisherListStats; @@ -268,14 +269,14 @@ async function getPublisherPublishedPreviewRows( const [skills, packages] = await Promise.all([ ctx.db .query("skills") - .withIndex("by_owner_publisher_active_installs", (q) => + .withIndex("by_owner_publisher_active_downloads", (q) => q.eq("ownerPublisherId", publisherId).eq("softDeletedAt", undefined), ) .order("desc") .take(PUBLISHER_LIST_PREVIEW_LIMIT), ctx.db .query("packages") - .withIndex("by_owner_publisher_active_installs", (q) => + .withIndex("by_owner_publisher_active_downloads", (q) => q.eq("ownerPublisherId", publisherId).eq("softDeletedAt", undefined), ) .order("desc") @@ -323,7 +324,7 @@ function getPublisherPublishedItems( })), ]; return items - .sort((a, b) => b.installs - a.installs || a.displayName.localeCompare(b.displayName)) + .sort((a, b) => b.downloads - a.downloads || a.displayName.localeCompare(b.displayName)) .slice(0, limit) .map((item) => ({ kind: item.kind, @@ -351,14 +352,14 @@ function comparePublisherCatalogItems(sort: PublisherCatalogSort) { if (sort === "recent") { return ( b.updatedAt - a.updatedAt || - b.installs - a.installs || + b.downloads - a.downloads || b.stars - a.stars || a.displayName.localeCompare(b.displayName) ); } return ( - b.installs - a.installs || + b.downloads - a.downloads || b.stars - a.stars || b.updatedAt - a.updatedAt || a.displayName.localeCompare(b.displayName) @@ -367,14 +368,14 @@ function comparePublisherCatalogItems(sort: PublisherCatalogSort) { } function normalizePublisherCatalogSort(sort?: PublisherCatalogSortArg): PublisherCatalogSort { - return sort === "recent" ? "recent" : "installs"; + return sort === "recent" ? "recent" : "downloads"; } function getPublisherCatalogItems( publisher: Doc<"publishers">, rows: PublisherPublishedRows, publisherOfficial: boolean, - sort: PublisherCatalogSort = "installs", + sort: PublisherCatalogSort = "downloads", ): PublisherCatalogItem[] { return [ ...rows.skills.map((skill) => ({ @@ -534,16 +535,71 @@ async function toVisiblePublisherListSummary( ): Promise { const visibility = await getPublicPublisherVisibility(ctx, publisher); if (!visibility) return null; + if (!hasPublisherStats(visibility.publisher)) { + const item = await toPublisherListItem(ctx, visibility.publisher, { + forceComputedStats: true, + visibility, + }); + return item ? { publisher: visibility.publisher, item, visibility } : null; + } const summary = toPublisherListSummary(visibility.publisher); if (!summary) return null; return { ...summary, visibility }; } function hasPublisherListContent(summary: PublisherListSummary) { - if (!hasPublisherStats(summary.publisher)) return true; return summary.item.stats.skills + summary.item.stats.packages > 0; } +function mergePublisherRows( + rankedRows: Doc<"publishers">[], + legacyRows: Doc<"publishers">[], +): Doc<"publishers">[] { + const rowsById = new Map, Doc<"publishers">>(); + for (const row of rankedRows) rowsById.set(row._id, row); + for (const row of legacyRows) rowsById.set(row._id, row); + return [...rowsById.values()]; +} + +async function getActivePublisherRowsByDownloads( + ctx: Pick, + kindFilter?: PublicPublisherKindFilter, +): Promise[]> { + const rankedRows = kindFilter + ? await ctx.db + .query("publishers") + .withIndex("by_active_kind_total_downloads", (q) => + q.eq("deletedAt", undefined).eq("deactivatedAt", undefined).eq("kind", kindFilter), + ) + .order("desc") + .take(MAX_PUBLIC_PUBLISHER_LIST_LIMIT) + : await ctx.db + .query("publishers") + .withIndex("by_active_total_downloads", (q) => + q.eq("deletedAt", undefined).eq("deactivatedAt", undefined), + ) + .order("desc") + .take(MAX_PUBLIC_PUBLISHER_LIST_LIMIT); + + const legacyRows = kindFilter + ? await ctx.db + .query("publishers") + .withIndex("by_active_kind_total_installs", (q) => + q.eq("deletedAt", undefined).eq("deactivatedAt", undefined).eq("kind", kindFilter), + ) + .order("desc") + .take(LEGACY_PUBLISHER_DOWNLOAD_FALLBACK_LIMIT) + : await ctx.db + .query("publishers") + .withIndex("by_active_total_installs", (q) => + q.eq("deletedAt", undefined).eq("deactivatedAt", undefined), + ) + .order("desc") + .take(LEGACY_PUBLISHER_DOWNLOAD_FALLBACK_LIMIT); + + return mergePublisherRows(rankedRows, legacyRows); +} + async function getVisiblePublisherListSummaries( ctx: Pick, publishers: Doc<"publishers">[], @@ -636,7 +692,7 @@ function comparePublisherListItems(a: PublisherListItem, b: PublisherListItem) { const bPublishedCount = b.stats.skills + b.stats.packages; return ( - b.stats.installs - a.stats.installs || + b.stats.downloads - a.stats.downloads || b.stats.stars - a.stats.stars || bPublishedCount - aPublishedCount || a.displayName.localeCompare(b.displayName) @@ -2067,7 +2123,7 @@ export const listPublic = query({ const kindFilter = args.kind as PublicPublisherKindFilter | undefined; const activeRows = await ctx.db .query("publishers") - .withIndex("by_active_total_installs", (q) => + .withIndex("by_active_total_downloads", (q) => q.eq("deletedAt", undefined).eq("deactivatedAt", undefined), ) .order("desc") @@ -2112,21 +2168,7 @@ export const listPublicPage = query({ const queryText = args.query?.trim(); const offset = args.paginationOpts.cursor ? Number(args.paginationOpts.cursor) : 0; const safeOffset = Number.isFinite(offset) && offset > 0 ? Math.trunc(offset) : 0; - const activeRows = kindFilter - ? await ctx.db - .query("publishers") - .withIndex("by_active_kind_total_installs", (q) => - q.eq("deletedAt", undefined).eq("deactivatedAt", undefined).eq("kind", kindFilter), - ) - .order("desc") - .take(MAX_PUBLIC_PUBLISHER_LIST_LIMIT) - : await ctx.db - .query("publishers") - .withIndex("by_active_total_installs", (q) => - q.eq("deletedAt", undefined).eq("deactivatedAt", undefined), - ) - .order("desc") - .take(MAX_PUBLIC_PUBLISHER_LIST_LIMIT); + const activeRows = await getActivePublisherRowsByDownloads(ctx, kindFilter); const publisherSummaries = await getVisiblePublisherListSummaries(ctx, activeRows); const itemSummaries = publisherSummaries .filter( @@ -2136,16 +2178,7 @@ export const listPublicPage = query({ ) .sort((a, b) => comparePublisherListItems(a.item, b.item)); const globalPublisherSummaries = kindFilter - ? await getVisiblePublisherListSummaries( - ctx, - await ctx.db - .query("publishers") - .withIndex("by_active_total_installs", (q) => - q.eq("deletedAt", undefined).eq("deactivatedAt", undefined), - ) - .order("desc") - .take(MAX_PUBLIC_PUBLISHER_LIST_LIMIT), - ) + ? await getVisiblePublisherListSummaries(ctx, await getActivePublisherRowsByDownloads(ctx)) : publisherSummaries; const globalCounts = getPublisherListSummaryCounts(globalPublisherSummaries); const counts = queryText ? getPublisherListSummaryCounts(itemSummaries) : globalCounts; diff --git a/convex/schema.ts b/convex/schema.ts index 6b07adf4..a484c082 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -1764,6 +1764,14 @@ const packageStatEvents = defineTable({ .index("by_unprocessed", ["processedAt"]) .index("by_package", ["packageId"]); +const packageDailyStats = defineTable({ + packageId: v.id("packages"), + day: v.number(), + downloads: v.number(), + installs: v.number(), + updatedAt: v.number(), +}).index("by_package_day", ["packageId", "day"]); + const packageTrustedPublishers = defineTable({ packageId: v.id("packages"), provider: v.literal("github-actions"), @@ -1915,6 +1923,53 @@ const packageTopicSearchDigest = defineTable({ "stats.downloads", "updatedAt", ]) + .index("by_active_family_topic_downloads", [ + "softDeletedAt", + "family", + "topic", + "stats.downloads", + "updatedAt", + ]) + .index("by_active_channel_topic_downloads", [ + "softDeletedAt", + "channel", + "topic", + "stats.downloads", + "updatedAt", + ]) + .index("by_active_family_channel_topic_downloads", [ + "softDeletedAt", + "family", + "channel", + "topic", + "stats.downloads", + "updatedAt", + ]) + .index("by_active_family_official_topic_downloads", [ + "softDeletedAt", + "family", + "isOfficial", + "topic", + "stats.downloads", + "updatedAt", + ]) + .index("by_active_channel_official_topic_downloads", [ + "softDeletedAt", + "channel", + "isOfficial", + "topic", + "stats.downloads", + "updatedAt", + ]) + .index("by_active_family_channel_official_topic_downloads", [ + "softDeletedAt", + "family", + "channel", + "isOfficial", + "topic", + "stats.downloads", + "updatedAt", + ]) .index("by_active_official_topic_installs", [ "softDeletedAt", "isOfficial", @@ -2010,6 +2065,21 @@ const packagePluginCategorySearchDigest = defineTable({ "stats.downloads", "updatedAt", ]) + .index("by_active_channel_category_downloads", [ + "softDeletedAt", + "channel", + "pluginCategory", + "stats.downloads", + "updatedAt", + ]) + .index("by_active_family_channel_category_downloads", [ + "softDeletedAt", + "family", + "channel", + "pluginCategory", + "stats.downloads", + "updatedAt", + ]) .index("by_active_family_category_installs", [ "softDeletedAt", "family", @@ -2053,6 +2123,23 @@ const packagePluginCategorySearchDigest = defineTable({ "stats.downloads", "updatedAt", ]) + .index("by_active_channel_official_category_downloads", [ + "softDeletedAt", + "channel", + "isOfficial", + "pluginCategory", + "stats.downloads", + "updatedAt", + ]) + .index("by_active_family_channel_official_category_downloads", [ + "softDeletedAt", + "family", + "channel", + "isOfficial", + "pluginCategory", + "stats.downloads", + "updatedAt", + ]) .index("by_active_family_official_category_installs", [ "softDeletedAt", "family", @@ -2776,6 +2863,7 @@ export default defineSchema({ skillScanRequestFileChunks, skillCardGenerationJobs, packageStatEvents, + packageDailyStats, packageTrustedPublishers, packagePublishTokens, packagePublishUploadTickets, diff --git a/convex/securityScan.test.ts b/convex/securityScan.test.ts index f30c36c0..fafaf041 100644 --- a/convex/securityScan.test.ts +++ b/convex/securityScan.test.ts @@ -24,6 +24,7 @@ import { vi.mock("@convex-dev/auth/server", () => ({ getAuthUserId: vi.fn(), + authTables: {}, })); type WrappedHandler = { diff --git a/convex/skills.packageCatalog.test.ts b/convex/skills.packageCatalog.test.ts index 6217d734..4e2856fc 100644 --- a/convex/skills.packageCatalog.test.ts +++ b/convex/skills.packageCatalog.test.ts @@ -428,7 +428,7 @@ describe("skills package catalog queries", () => { expect(indexNames).not.toContain("by_active_topic_updated"); }); - it("falls topic recommendation sorting back to installs while scores are missing", async () => { + it("falls topic recommendation sorting back to downloads while scores are missing", async () => { const indexNames: string[] = []; const calendarSkill = makeDigest("calendar-skill", { topics: ["calendar"] }); @@ -444,7 +444,7 @@ describe("skills package catalog queries", () => { }, ], isDone: false, - continueCursor: "installs-next", + continueCursor: "downloads-next", }, ], [calendarSkill], @@ -459,9 +459,9 @@ describe("skills package catalog queries", () => { ); expect(result.page.map((entry) => entry.name)).toEqual(["calendar-skill"]); - expect(indexNames).toContain("by_active_topic_installs"); + expect(indexNames).toContain("by_active_topic_downloads"); expect(indexNames).not.toContain("by_active_topic_recommended_score"); - expect(result.continueCursor).toContain('"recommendedFallback":"installs"'); + expect(result.continueCursor).toContain('"recommendedFallback":"downloads"'); }); it("keeps legacy topic recommendation fallback cursors on the updated index", async () => { @@ -620,7 +620,7 @@ describe("skills package catalog queries", () => { ]); }); - it("falls back to installs sort for recommended package catalog rows while scores backfill", async () => { + it("falls back to downloads sort for recommended package catalog rows while scores backfill", async () => { const indexNames: string[] = []; const result = await listPackageCatalogPageHandler( makeCtx( @@ -647,10 +647,10 @@ describe("skills package catalog queries", () => { expect(indexNames).toEqual([ "by_active_recommended_score", "by_active_recommended_score_version", - "by_active_stats_installs_all_time", + "by_active_stats_downloads", ]); expect(result.page).toEqual([expect.objectContaining({ name: "fallback-skill" })]); - expect(result.continueCursor).toContain('"recommendedFallback":"installs"'); + expect(result.continueCursor).toContain('"recommendedFallback":"downloads"'); }); it("uses the recommended score index for recommended package catalog rows", async () => { @@ -681,13 +681,13 @@ describe("skills package catalog queries", () => { expect(result.page).toEqual([expect.objectContaining({ name: "recommended-skill" })]); }); - it("falls recommended package catalog rows back to installs when scores are missing", async () => { + it("falls recommended package catalog rows back to downloads when scores are missing", async () => { const indexNames: string[] = []; const result = await listPackageCatalogPageHandler( makeCtx( [ { - page: [makeDigest("install-fallback-skill")], + page: [makeDigest("download-fallback-skill")], isDone: false, continueCursor: "updated-next", }, @@ -700,12 +700,9 @@ describe("skills package catalog queries", () => { }, ); - expect(indexNames).toEqual([ - "by_active_recommended_score", - "by_active_stats_installs_all_time", - ]); - expect(result.page).toEqual([expect.objectContaining({ name: "install-fallback-skill" })]); - expect(result.continueCursor).toContain('"recommendedFallback":"installs"'); + expect(indexNames).toEqual(["by_active_recommended_score", "by_active_stats_downloads"]); + expect(result.page).toEqual([expect.objectContaining({ name: "download-fallback-skill" })]); + expect(result.continueCursor).toContain('"recommendedFallback":"downloads"'); }); it("keeps recommended package catalog cursors on their original index", async () => { @@ -781,6 +778,37 @@ describe("skills package catalog queries", () => { expect(result.continueCursor).toContain('"recommendedFallback":"updated"'); }); + it("resets legacy installs fallback cursors before using downloads", async () => { + const indexNames: string[] = []; + const fallbackCursor = `skillcat:${JSON.stringify({ + cursor: "legacy-install-next", + offset: 2, + pageSize: 3, + done: false, + recommendedFallback: "installs", + })}`; + const result = await listPackageCatalogPageHandler( + makeCtx( + [ + { + page: [makeDigest("download-fallback-skill")], + isDone: true, + continueCursor: "", + }, + ], + { indexNames, missingRecommendedScores: true }, + ), + { + sort: "recommended", + paginationOpts: { cursor: fallbackCursor, numItems: 1 }, + }, + ); + + expect(indexNames).toEqual(["by_active_stats_downloads"]); + expect(result.page).toEqual([expect.objectContaining({ name: "download-fallback-skill" })]); + expect(result.continueCursor).toBe(""); + }); + it("searches skills with package-style lexical scoring", async () => { const result = await searchPackageCatalogPublicHandler( makeCtx([ diff --git a/convex/skills.public.test.ts b/convex/skills.public.test.ts index 3bffe643..fa5580e0 100644 --- a/convex/skills.public.test.ts +++ b/convex/skills.public.test.ts @@ -16,6 +16,7 @@ const { getAuthUserId } = await import("@convex-dev/auth/server"); const { getSkillBadgeMap } = await import("./lib/badges"); const skillsModule = await import("./skills"); const { + getActivityTrendForSlug, getBySlug, getVerifyTargetBySlugInternal, listSkillReportsInternal, @@ -84,6 +85,24 @@ const getBySlugHandler = ( > )._handler; +const getActivityTrendForSlugHandler = ( + getActivityTrendForSlug as unknown as WrappedHandler< + { + slug: string; + ownerHandle?: string; + endDay: number; + }, + { + downloads: { + range: "daily"; + days: number; + total: number; + points: Array<{ day: number; value: number }>; + }; + } | null + > +)._handler; + const getGitHubScanForAuditHandler = ( skillsModule as typeof skillsModule & { getGitHubScanForAudit?: WrappedHandler< @@ -211,6 +230,7 @@ function makeCtx(args: { membership?: Record | null; latestVersion?: Record | null; githubScan?: Record | null; + skillDailyStats?: Array>; skillsById?: Record>; ownersById?: Record>; }) { @@ -238,6 +258,13 @@ function makeCtx(args: { })), }; } + if (table === "skillDailyStats") { + return { + withIndex: vi.fn(() => ({ + take: vi.fn().mockResolvedValue(args.skillDailyStats ?? []), + })), + }; + } if (table !== "skills") throw new Error(`Unexpected query table: ${table}`); return { withIndex }; }); @@ -556,6 +583,64 @@ describe("skills.getBySlug", () => { expect(result).toBeNull(); }); + it("hides activity trends when the skill owner is not public", async () => { + const ctx = makeCtx({ + skill: makeSkill(), + owner: makeOwner("users:1", "demo-owner", { deletedAt: 123 }), + skillDailyStats: [{ day: 25, downloads: 4, installs: 2 }], + }); + + const result = await getActivityTrendForSlugHandler(ctx, { slug: "demo", endDay: 25 } as never); + + expect(result).toBeNull(); + }); + + it("returns daily activity trends for public skills", async () => { + const ctx = makeCtx({ + skill: makeSkill(), + owner: makeOwner("users:1", "demo-owner"), + skillDailyStats: [ + { day: 24, downloads: 4, installs: 2 }, + { day: 25, downloads: 3, installs: 1 }, + ], + }); + + const result = await getActivityTrendForSlugHandler(ctx, { slug: "demo", endDay: 25 } as never); + + expect(result?.downloads.range).toBe("daily"); + expect(result?.downloads.days).toBe(30); + expect(result?.downloads.total).toBe(7); + expect(result?.downloads.points).toHaveLength(30); + expect(result?.downloads.points[0]).toEqual({ day: -4, value: 0 }); + expect(result?.downloads.points.at(-1)).toEqual({ day: 25, value: 3 }); + expect(result && "installs" in result).toBe(false); + }); + + it("clamps future activity trend end days to the current UTC day", async () => { + const now = Date.UTC(2026, 5, 19, 12); + const todayDay = Math.floor(now / 86_400_000); + vi.useFakeTimers(); + vi.setSystemTime(now); + + try { + const ctx = makeCtx({ + skill: makeSkill(), + owner: makeOwner("users:1", "demo-owner"), + skillDailyStats: [{ day: todayDay, downloads: 5, installs: 2 }], + }); + + const result = await getActivityTrendForSlugHandler(ctx, { + slug: "demo", + endDay: todayDay + 10, + } as never); + + expect(result?.downloads.points.at(-1)).toEqual({ day: todayDay, value: 5 }); + expect(result?.downloads.total).toBe(5); + } finally { + vi.useRealTimers(); + } + }); + it("does not honor stale personal memberships for hidden skill owner views", async () => { vi.mocked(getAuthUserId).mockResolvedValue("users:stranger" as never); const ctx = makeCtx({ diff --git a/convex/skills.publicListCursor.test.ts b/convex/skills.publicListCursor.test.ts index b4111d06..b51c90aa 100644 --- a/convex/skills.publicListCursor.test.ts +++ b/convex/skills.publicListCursor.test.ts @@ -1106,6 +1106,31 @@ describe("public skill list deterministic cursors", () => { expect(result.items[0]).toMatchObject({ latestVersion: null }); }); + it("carries author topics through the public API list", async () => { + getPageMock.mockResolvedValueOnce({ + page: [ + makeSearchDigest({ + topics: ["Calendar", "Official"], + }), + ], + hasMore: false, + indexKeys: [], + }); + + const result = await listPublicApiPageV1Handler({} as never, { + numItems: 10, + sort: "updated", + }); + + expect(result.items).toHaveLength(1); + expect(result.items[0]).toMatchObject({ + skill: { + slug: "demo", + topics: ["Calendar", "Official"], + }, + }); + }); + it("keeps verified legacy API list latest versions without owner markers", async () => { getPageMock.mockResolvedValueOnce({ page: [ @@ -1295,6 +1320,35 @@ describe("public skill list deterministic cursors", () => { }); }); + it("orders public audit skills by downloads", async () => { + const digest = makeSearchDigest({ latestVersionId: undefined }); + const withIndex = vi.fn(() => ({ + order: vi.fn(() => ({ + paginate: vi.fn().mockResolvedValue({ + page: [digest], + isDone: true, + continueCursor: "", + }), + })), + })); + const ctx = { + db: { + query: vi.fn((table: string) => { + if (table !== "skillSearchDigest") throw new Error(`unexpected table ${table}`); + return { withIndex }; + }), + get: vi.fn(), + }, + }; + + const result = await listAuditPageHandler(ctx as never, { + paginationOpts: { cursor: null, numItems: 10 }, + }); + + expect(result.page).toHaveLength(1); + expect(withIndex).toHaveBeenCalledWith("by_active_stats_downloads", expect.any(Function)); + }); + it("drops audit latest versions that resolve to another skill", async () => { const digest = makeSearchDigest({ latestVersionId: "skillVersions:other", @@ -1341,10 +1395,6 @@ describe("public skill list deterministic cursors", () => { expect(result.page).toHaveLength(1); expect(result.page[0]).toMatchObject({ latestVersion: null }); - expect(withIndex).toHaveBeenCalledWith( - "by_active_stats_installs_all_time", - expect.any(Function), - ); }); }); diff --git a/convex/skills.resolve.test.ts b/convex/skills.resolve.test.ts index 47ee5a7c..71f4e5de 100644 --- a/convex/skills.resolve.test.ts +++ b/convex/skills.resolve.test.ts @@ -3,8 +3,8 @@ import { hashSkillFiles } from "./lib/skills"; import { resolveVersionByHash } from "./skills"; vi.mock("@convex-dev/auth/server", () => ({ - authTables: {}, getAuthUserId: vi.fn(), + authTables: {}, })); type WrappedHandler = { diff --git a/convex/skills.ts b/convex/skills.ts index 3b5f3b68..145f1c13 100644 --- a/convex/skills.ts +++ b/convex/skills.ts @@ -44,6 +44,12 @@ import { import { getSkillBadgeMap, getSkillBadgeMaps, isSkillHighlighted } from "./lib/badges"; import { scheduleNextBatchIfNeeded } from "./lib/batching"; import { generateChangelogPreview as buildChangelogPreview } from "./lib/changelog"; +import { + ACTIVITY_TREND_DAYS, + buildDailyMetricTrends, + clampActivityTrendEndDay, + getActivityTrendRangeForEndDay, +} from "./lib/downloadTrend"; import { embeddingVisibilityFor } from "./lib/embeddingVisibility"; import { canHealSkillOwnershipByGitHubProviderAccountId, @@ -2262,6 +2268,23 @@ function toPublicSkillListVersionFromSummary( }; } +async function buildSkillActivityTrend( + ctx: Pick, + skill: Doc<"skills">, + endDay: number, +) { + const safeEndDay = clampActivityTrendEndDay(endDay, Date.now()); + const { startDay, endDay: normalizedEndDay } = getActivityTrendRangeForEndDay(safeEndDay); + const rows = await ctx.db + .query("skillDailyStats") + .withIndex("by_skill_day", (q) => + q.eq("skillId", skill._id).gte("day", startDay).lte("day", normalizedEndDay), + ) + .take(ACTIVITY_TREND_DAYS); + + return buildDailyMetricTrends(rows, normalizedEndDay); +} + async function buildManagementSkillEntries(ctx: QueryCtx, skills: Doc<"skills">[]) { const ownerCache = new Map, Promise | null>>(); const badgeMapBySkillId = await getSkillBadgeMaps( @@ -3116,6 +3139,22 @@ export const getSkillBySlugInternal = internalQuery({ }, }); +export const getActivityTrendForSlug = query({ + args: { slug: v.string(), ownerHandle: v.optional(v.string()), endDay: v.number() }, + handler: async (ctx, args) => { + const resolved = await resolveSkillBySlugOrAliasForOwner(ctx, args.slug, args.ownerHandle); + const skill = resolved.skill; + if (!skill || !isPublicSkillDoc(skill)) return null; + const ownerPublisher = await getOwnerPublisher(ctx, { + ownerPublisherId: skill.ownerPublisherId, + ownerUserId: skill.ownerUserId, + }); + if (!toPublicPublisher(ownerPublisher)) return null; + + return await buildSkillActivityTrend(ctx, skill, args.endDay); + }, +}); + export const getSkillForPublishPreflightInternal = internalQuery({ args: { userId: v.id("users"), @@ -5826,7 +5865,7 @@ export const listAuditPage = query({ const { numItems, cursor } = normalizePublicListPagination(args.paginationOpts); const result = await ctx.db .query("skillSearchDigest") - .withIndex("by_active_stats_installs_all_time", (q) => q.eq("softDeletedAt", undefined)) + .withIndex("by_active_stats_downloads", (q) => q.eq("softDeletedAt", undefined)) .order("desc") .paginate({ cursor, numItems }); @@ -6101,18 +6140,24 @@ type SkillCatalogCursorState = { recommendedFallback?: SkillCatalogRecommendedFallbackSort; }; -type SkillCatalogRecommendedFallbackSort = "updated" | "installs"; +type SkillCatalogRecommendedFallbackSort = "updated" | "downloads"; -const SKILL_CATALOG_RECOMMENDED_FALLBACK_SORT = "installs" as const; +const SKILL_CATALOG_RECOMMENDED_FALLBACK_SORT = "downloads" as const; function normalizeSkillCatalogRecommendedFallbackSort( value: unknown, ): SkillCatalogRecommendedFallbackSort | undefined { + if (value === "installs") return "downloads"; return value === "updated" || value === SKILL_CATALOG_RECOMMENDED_FALLBACK_SORT ? value : undefined; } +function readSkillCatalogCursorField(input: unknown, field: string): unknown { + if (input === null || typeof input !== "object") return undefined; + return Object.getOwnPropertyDescriptor(input, field)?.value; +} + function encodeSkillCatalogCursor(state: SkillCatalogCursorState) { if (state.done && state.offset === 0) return ""; return `${SKILL_CATALOG_CURSOR_PREFIX}${JSON.stringify(state)}`; @@ -6124,15 +6169,26 @@ function decodeSkillCatalogCursor(raw: string | null | undefined): SkillCatalogC return { cursor: raw, offset: 0, pageSize: null, done: false }; } try { - const parsed = JSON.parse( - raw.slice(SKILL_CATALOG_CURSOR_PREFIX.length), - ) as Partial; + const parsed: unknown = JSON.parse(raw.slice(SKILL_CATALOG_CURSOR_PREFIX.length)); + const recommendedFallbackValue = readSkillCatalogCursorField(parsed, "recommendedFallback"); + const resetLegacyInstallCursorState = recommendedFallbackValue === "installs"; + const cursorValue = readSkillCatalogCursorField(parsed, "cursor"); + const offsetValue = readSkillCatalogCursorField(parsed, "offset"); + const pageSizeValue = readSkillCatalogCursorField(parsed, "pageSize"); + const doneValue = readSkillCatalogCursorField(parsed, "done"); return { - cursor: typeof parsed.cursor === "string" ? parsed.cursor : null, - offset: typeof parsed.offset === "number" && parsed.offset > 0 ? parsed.offset : 0, - pageSize: typeof parsed.pageSize === "number" && parsed.pageSize > 0 ? parsed.pageSize : null, - done: parsed.done === true, - recommendedFallback: normalizeSkillCatalogRecommendedFallbackSort(parsed.recommendedFallback), + cursor: + !resetLegacyInstallCursorState && typeof cursorValue === "string" ? cursorValue : null, + offset: + !resetLegacyInstallCursorState && typeof offsetValue === "number" && offsetValue > 0 + ? offsetValue + : 0, + pageSize: + !resetLegacyInstallCursorState && typeof pageSizeValue === "number" && pageSizeValue > 0 + ? pageSizeValue + : null, + done: !resetLegacyInstallCursorState && doneValue === true, + recommendedFallback: normalizeSkillCatalogRecommendedFallbackSort(recommendedFallbackValue), }; } catch { return { cursor: null, offset: 0, pageSize: null, done: false }; diff --git a/convex/stars.test.ts b/convex/stars.test.ts index 05241411..1b914ba9 100644 --- a/convex/stars.test.ts +++ b/convex/stars.test.ts @@ -8,6 +8,7 @@ vi.mock("./skillStatEvents", () => ({ vi.mock("@convex-dev/auth/server", () => ({ getAuthUserId: vi.fn(), + authTables: {}, })); vi.mock("./functions", () => ({ diff --git a/convex/users.test.ts b/convex/users.test.ts index d1f066b0..10c9c68a 100644 --- a/convex/users.test.ts +++ b/convex/users.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; vi.mock("@convex-dev/auth/server", () => ({ getAuthUserId: vi.fn(), + authTables: {}, })); vi.mock("./lib/access", async () => { @@ -1465,6 +1466,58 @@ describe("users.getHoverStats", () => { expect(takeLimits).toHaveLength(4); expect(takeLimits.every((limit) => limit > 0 && limit <= 200)).toBe(true); }); + + it("uses installs as the download fallback for legacy hover aggregates", async () => { + const get = vi.fn(async (id: string) => { + if (id === "users:owner") { + return { + _id: "users:owner", + _creationTime: 1, + handle: "owner", + displayName: "Owner", + name: "Owner", + email: "owner@example.com", + role: "user", + createdAt: 1, + personalPublisherId: "publishers:owner", + }; + } + if (id === "publishers:owner") { + return { + _id: "publishers:owner", + _creationTime: 1, + kind: "user", + handle: "owner", + displayName: "Owner", + publishedSkills: 4, + totalStars: 5, + totalInstalls: 37, + createdAt: 1, + updatedAt: 1, + }; + } + return null; + }); + + const result = await getHoverStatsHandler( + { + db: { + get, + query: vi.fn(() => { + throw new Error("publisher lookup should use personalPublisherId"); + }), + }, + }, + { userId: "users:owner" }, + ); + + expect(result).toEqual({ + publishedSkills: 4, + totalStars: 5, + totalDownloads: 37, + totalInstalls: 37, + }); + }); }); describe("users.ensurePublisherHandleInternal", () => { diff --git a/convex/users.ts b/convex/users.ts index 97139be8..603dd671 100644 --- a/convex/users.ts +++ b/convex/users.ts @@ -1324,12 +1324,13 @@ export const getHoverStats = query({ ? (publisher.totalInstalls ?? (await getPublisherInstallFallback(ctx, publisher._id, user._id))) : 0; + const totalDownloads = publisher?.totalDownloads ?? user?.totalDownloads ?? totalInstalls; return { publishedSkills: publisher?.publishedSkills ?? user?.publishedSkills ?? 0, totalStars: publisher?.totalStars ?? user?.totalStars ?? 0, // Older cached frontend bundles still read this field during rollout. - totalDownloads: publisher?.totalDownloads ?? user?.totalDownloads ?? 0, + totalDownloads, totalInstalls, }; }, diff --git a/docs/api.md b/docs/api.md index 8aea9d6d..c9b79380 100644 --- a/docs/api.md +++ b/docs/api.md @@ -83,7 +83,7 @@ Public read: - Optional filters: `highlightedOnly=true`, `nonSuspiciousOnly=true` - Legacy alias: `nonSuspicious=true` - `GET /api/v1/skills?limit=&cursor=&sort=` - - `sort`: `updated` (default), `recommended` (`default`), `createdAt` (`newest`), `stars` (`rating`), `installsCurrent` (`installs`), `installsAllTime`, `trending` + - `sort`: `updated` (default), `recommended` (`default`), `createdAt` (`newest`), `downloads`, `stars` (`rating`), legacy install aliases `installsCurrent`/`installs`/`installsAllTime` map to `downloads`, `trending` - Invalid `sort` values return `400` - `cursor` applies to non-`trending` sorts - Optional filter: `nonSuspiciousOnly=true` @@ -99,10 +99,10 @@ Public read: - `GET /api/v1/resolve?slug=&hash=` - `GET /api/v1/download?slug=&version=&tag=` - `GET /api/v1/packages?limit=&cursor=&sort=` - - `sort`: `updated` (default), `recommended`, `installs` + - `sort`: `updated` (default), `recommended`, `downloads`, legacy alias `installs` - Invalid `sort` values return `400` - `GET /api/v1/plugins?limit=&cursor=&sort=` - - `sort`: `recommended` (default), `installs`, `updated` + - `sort`: `recommended` (default), `downloads`, `updated`, legacy alias `installs` - `GET /api/v1/plugins/search?q=...` - `GET /api/v1/packages/{name}/versions/{version}/artifact` - `GET /api/v1/packages/{name}/versions/{version}/security` diff --git a/docs/clawhub.md b/docs/clawhub.md index 94a21dc3..43063c22 100644 --- a/docs/clawhub.md +++ b/docs/clawhub.md @@ -54,7 +54,7 @@ pnpm add -g clawhub | Bundle plugins | Packaged plugin bundles for OpenClaw distribution | `clawhub package publish ` | ClawHub tracks semver versions, tags such as `latest`, changelogs, files, -installs, stars, and security scan summaries. Public pages show current registry +downloads, stars, and security scan summaries. Public pages show current registry state so users can inspect a skill or plugin before installing it. ## Native OpenClaw flows diff --git a/docs/cli.md b/docs/cli.md index 7a3d9fa4..484beb54 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -113,7 +113,7 @@ Stores your API token + cached registry URL. - Lists newest skills via `/api/v1/skills?limit=...&sort=createdAt` (sorted by `createdAt` desc). - Flags: - `--limit ` (1-200, default: 25) - - `--sort newest|updated|rating|installs|installsAllTime|trending` (default: newest) + - `--sort newest|updated|rating|downloads|trending` (default: newest). Legacy install sort aliases still work for compatibility. - `--json` (machine-readable output) - Output: ` v ` (summary truncated to 50 chars). diff --git a/docs/http-api.md b/docs/http-api.md index 4e753a6e..bd6c746a 100644 --- a/docs/http-api.md +++ b/docs/http-api.md @@ -142,7 +142,7 @@ Query params: - `limit` (optional): integer (1–200) - `cursor` (optional): pagination cursor for any non-`trending` sort -- `sort` (optional): `updated` (default), `recommended` (alias: `default`), `createdAt` (alias: `newest`), `stars` (alias: `rating`), `installsCurrent` (alias: `installs`), `installsAllTime`, `trending` +- `sort` (optional): `updated` (default), `recommended` (alias: `default`), `createdAt` (alias: `newest`), `downloads`, `stars` (alias: `rating`), legacy install aliases `installsCurrent`/`installs`/`installsAllTime` map to `downloads`, `trending` - `nonSuspiciousOnly` (optional): `true` to hide suspicious (`flagged.suspicious`) skills - `nonSuspicious` (optional): legacy alias for `nonSuspiciousOnly` @@ -165,6 +165,7 @@ Response: "slug": "gifgrep", "displayName": "GifGrep", "summary": "…", + "topics": ["Productivity"], "tags": { "latest": "1.2.3" }, "stats": {}, "createdAt": 0, @@ -187,6 +188,7 @@ Response: "slug": "gifgrep", "displayName": "GifGrep", "summary": "…", + "topics": ["Productivity"], "tags": { "latest": "1.2.3" }, "stats": {}, "createdAt": 0, @@ -539,7 +541,7 @@ Query params: - `family` (optional): `skill`, `code-plugin`, or `bundle-plugin` - `channel` (optional): `official`, `community`, or `private` - `isOfficial` (optional): `true` or `false` -- `sort` (optional): `updated` (default), `recommended`, `installs` +- `sort` (optional): `updated` (default), `recommended`, `downloads`, legacy alias `installs` - `category` (optional): plugin category filter. Supported only when the request is scoped to plugin packages (`/api/v1/plugins`, `/api/v1/code-plugins`, `/api/v1/bundle-plugins`, or package endpoints with @@ -589,7 +591,7 @@ Query params: - `limit` (optional): integer (1-100) - `cursor` (optional): pagination cursor - `isOfficial` (optional): `true` or `false` -- `sort` (optional): `recommended` (default), `installs`, `updated` +- `sort` (optional): `recommended` (default), `downloads`, `updated`, legacy alias `installs` - `category` (optional): plugin category filter. Current values: `channels`, `models`, `memory`, `context`, `voice`, `media`, `web`, `tools`, `runtime`, `gateway`, `security`, `other`. diff --git a/docs/security-audits.md b/docs/security-audits.md index 1df13a23..21dfb121 100644 --- a/docs/security-audits.md +++ b/docs/security-audits.md @@ -29,7 +29,7 @@ Before installing, review: - the risk level - any listed findings - required credentials, permissions, or environment variables -- owner, source, version, changelog, installs, stars, and other trust signals +- owner, source, version, changelog, downloads, stars, and other trust signals Install only content you understand and trust. diff --git a/packages/clawhub/src/cli.ts b/packages/clawhub/src/cli.ts index 33781266..fd3ea03a 100644 --- a/packages/clawhub/src/cli.ts +++ b/packages/clawhub/src/cli.ts @@ -351,11 +351,7 @@ registerCommand(program, ["explore"]) (value) => Number.parseInt(value, 10), 25, ) - .option( - "--sort ", - "Sort by newest, rating, installs, installsAllTime, or trending", - "newest", - ) + .option("--sort ", "Sort by newest, rating, downloads, or trending", "newest") .option("--json", "Output JSON") .action(async (options) => { const opts = await resolveGlobalOpts(); diff --git a/packages/clawhub/src/cli/commands/skills.test.ts b/packages/clawhub/src/cli/commands/skills.test.ts index ed2ce58d..91a0993c 100644 --- a/packages/clawhub/src/cli/commands/skills.test.ts +++ b/packages/clawhub/src/cli/commands/skills.test.ts @@ -248,17 +248,36 @@ describe("cmdExplore", () => { } }); - it("keeps legacy download aliases on the all-time install sort", async () => { + it("supports downloads sort", async () => { mockApiRequest.mockResolvedValue({ items: [], nextCursor: null }); await cmdExplore(makeOpts(), { sort: "downloads" }); await cmdExplore(makeOpts(), { sort: "download" }); + for (const call of mockApiRequest.mock.calls) { + const url = new URL(String(call[1]?.url)); + expect(url.searchParams.get("sort")).toBe("downloads"); + } + }); + + it("keeps legacy install aliases on the all-time install sort", async () => { + mockApiRequest.mockResolvedValue({ items: [], nextCursor: null }); + + await cmdExplore(makeOpts(), { sort: "installs" }); + await cmdExplore(makeOpts(), { sort: "install" }); + for (const call of mockApiRequest.mock.calls) { const url = new URL(String(call[1]?.url)); expect(url.searchParams.get("sort")).toBe("installsAllTime"); } }); + + it("lists accepted legacy install aliases in invalid sort guidance", async () => { + await expect(cmdExplore(makeOpts(), { sort: "bad-sort" })).rejects.toThrow( + 'Invalid sort "bad-sort". Use newest, updated, rating, downloads, installs, installs-current, installs-all-time, or trending.', + ); + expect(mockApiRequest).not.toHaveBeenCalled(); + }); }); describe("cmdSearch", () => { diff --git a/packages/clawhub/src/cli/commands/skills.ts b/packages/clawhub/src/cli/commands/skills.ts index c7d55a9c..da4d78ff 100644 --- a/packages/clawhub/src/cli/commands/skills.ts +++ b/packages/clawhub/src/cli/commands/skills.ts @@ -847,10 +847,11 @@ export async function cmdUninstall( } } -type ExploreSort = "newest" | "rating" | "installs" | "installsAllTime" | "trending"; +type ExploreSort = "newest" | "rating" | "downloads" | "installs" | "installsAllTime" | "trending"; type ApiExploreSort = | "createdAt" | "updated" + | "downloads" | "stars" | "installsCurrent" | "installsAllTime" @@ -1081,12 +1082,10 @@ function resolveExploreSort(raw?: string): { sort: ExploreSort; apiSort: ApiExpl if (normalized === "rating" || normalized === "stars" || normalized === "star") { return { sort: "rating", apiSort: "stars" }; } - if ( - normalized === "installs" || - normalized === "install" || - normalized === "downloads" || - normalized === "download" - ) { + if (normalized === "downloads" || normalized === "download") { + return { sort: "downloads", apiSort: "downloads" }; + } + if (normalized === "installs" || normalized === "install") { return { sort: "installs", apiSort: "installsAllTime" }; } if ( @@ -1103,7 +1102,7 @@ function resolveExploreSort(raw?: string): { sort: ExploreSort; apiSort: ApiExpl return { sort: "trending", apiSort: "trending" }; } return fail( - `Invalid sort "${raw}". Use newest, updated, rating, installs, installsAllTime, or trending.`, + `Invalid sort "${raw}". Use newest, updated, rating, downloads, installs, installs-current, installs-all-time, or trending.`, ); } diff --git a/public/api/v1/openapi.json b/public/api/v1/openapi.json index c161b5ef..58088d3f 100644 --- a/public/api/v1/openapi.json +++ b/public/api/v1/openapi.json @@ -1469,6 +1469,20 @@ "type": "string" } }, + { + "name": "sort", + "in": "query", + "required": false, + "schema": { + "type": "string", + "enum": [ + "updated", + "recommended", + "downloads", + "installs" + ] + } + }, { "name": "channel", "in": "query", @@ -1543,6 +1557,20 @@ "type": "string" } }, + { + "name": "sort", + "in": "query", + "required": false, + "schema": { + "type": "string", + "enum": [ + "updated", + "recommended", + "downloads", + "installs" + ] + } + }, { "name": "channel", "in": "query", @@ -1662,6 +1690,20 @@ "type": "string" } }, + { + "name": "sort", + "in": "query", + "required": false, + "schema": { + "type": "string", + "enum": [ + "updated", + "recommended", + "downloads", + "installs" + ] + } + }, { "name": "family", "in": "query", @@ -2468,6 +2510,20 @@ "type": "string" } }, + { + "name": "sort", + "in": "query", + "required": false, + "schema": { + "type": "string", + "enum": [ + "updated", + "recommended", + "downloads", + "installs" + ] + } + }, { "name": "channel", "in": "query", @@ -2737,6 +2793,7 @@ "updated", "createdAt", "newest", + "downloads", "stars", "rating", "installsCurrent", diff --git a/specs/download-metering.md b/specs/download-metering.md index 0346379c..cd18d4ef 100644 --- a/specs/download-metering.md +++ b/specs/download-metering.md @@ -31,3 +31,16 @@ downloads ``` Existing historical counts are not estimated or rewritten in this phase. + +## Daily Package Graph Rollout + +Package daily rows start when the backend that writes `packageDailyStats` is +deployed. Production deploys are manual, so package graphs must not assume the +PR merge date is the first trustworthy daily-stat day. + +Operators set the Convex env var `PACKAGE_DAILY_STATS_ROLLOUT_AT` to the actual +production backend rollout time, as an ISO timestamp or Unix epoch +milliseconds. Existing packages with all-time downloads or installs keep using +all-time metadata until the visible 30-day graph window starts on or after that +rollout time. If the env var is missing or invalid, those package daily graphs +stay hidden rather than showing an undercount. diff --git a/src/__tests__/openapi-contract.test.ts b/src/__tests__/openapi-contract.test.ts index 2698ea66..b36b7a19 100644 --- a/src/__tests__/openapi-contract.test.ts +++ b/src/__tests__/openapi-contract.test.ts @@ -7,25 +7,29 @@ function property(value: unknown, key: string) { return Reflect.get(value, key); } +function sortValuesForPath(paths: unknown, path: string) { + const routePath = property(paths, path); + const getOperation = property(routePath, "get"); + const parameters = property(getOperation, "parameters"); + const sortParameter = Array.isArray(parameters) + ? parameters.find((parameter) => property(parameter, "name") === "sort") + : undefined; + return property(property(sortParameter, "schema"), "enum"); +} + describe("OpenAPI contract", () => { it("documents accepted skills sort aliases", async () => { const specPath = new URL("../../public/api/v1/openapi.json", import.meta.url); const spec: unknown = JSON.parse(await readFile(specPath, "utf8")); const paths = property(spec, "paths"); - const skillsPath = property(paths, "/api/v1/skills"); - const getOperation = property(skillsPath, "get"); - const parameters = property(getOperation, "parameters"); - const sortParameter = Array.isArray(parameters) - ? parameters.find((parameter) => property(parameter, "name") === "sort") - : undefined; - const sortValues = property(property(sortParameter, "schema"), "enum"); - expect(sortValues).toEqual([ + expect(sortValuesForPath(paths, "/api/v1/skills")).toEqual([ "recommended", "default", "updated", "createdAt", "newest", + "downloads", "stars", "rating", "installsCurrent", @@ -34,4 +38,16 @@ describe("OpenAPI contract", () => { "trending", ]); }); + + it("documents package and plugin downloads sort aliases", async () => { + const specPath = new URL("../../public/api/v1/openapi.json", import.meta.url); + const spec: unknown = JSON.parse(await readFile(specPath, "utf8")); + const paths = property(spec, "paths"); + const sortValues = ["updated", "recommended", "downloads", "installs"]; + + expect(sortValuesForPath(paths, "/api/v1/packages")).toEqual(sortValues); + expect(sortValuesForPath(paths, "/api/v1/plugins")).toEqual(sortValues); + expect(sortValuesForPath(paths, "/api/v1/code-plugins")).toEqual(sortValues); + expect(sortValuesForPath(paths, "/api/v1/bundle-plugins")).toEqual(sortValues); + }); }); diff --git a/src/__tests__/package-detail-route.test.tsx b/src/__tests__/package-detail-route.test.tsx index ebd17dd0..c04cd8a2 100644 --- a/src/__tests__/package-detail-route.test.tsx +++ b/src/__tests__/package-detail-route.test.tsx @@ -5,7 +5,7 @@ import { act, fireEvent, render, screen, waitFor } from "@testing-library/react" import { getFunctionName } from "convex/server"; import type { AnchorHTMLAttributes, ComponentType, ReactNode } from "react"; import { toast } from "sonner"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { fetchPackageDetail, fetchPackageFile, @@ -22,6 +22,8 @@ const isRateLimitedPackageApiErrorMock = vi.fn( ); const useQueryMock = vi.fn(); const useMutationMock = vi.fn(); +const convexQueryMock = vi.fn(); +const convexClientMock = { query: convexQueryMock }; const useAuthStatusMock = vi.fn(); const routerInvalidateMock = vi.fn(); let pathnameMock = "/plugins/demo-plugin"; @@ -92,6 +94,7 @@ vi.mock("@tanstack/react-router", () => ({ })); vi.mock("convex/react", () => ({ + useConvex: () => convexClientMock, useQuery: (...args: unknown[]) => useQueryMock(...args), useMutation: (...args: unknown[]) => useMutationMock(...args), })); @@ -184,6 +187,8 @@ describe("plugin detail route", () => { useQueryMock.mockReturnValue(undefined); useMutationMock.mockReset(); useMutationMock.mockReturnValue(vi.fn()); + convexQueryMock.mockReset(); + convexQueryMock.mockResolvedValue(null); useAuthStatusMock.mockReset(); routerInvalidateMock.mockReset(); vi.mocked(toast.error).mockReset(); @@ -195,6 +200,10 @@ describe("plugin detail route", () => { }); }); + afterEach(() => { + vi.useRealTimers(); + }); + it("hides download actions when the plugin has no latest release", async () => { const route = await loadRoute(); const Component = route.__config.component as ComponentType; @@ -807,7 +816,7 @@ describe("plugin detail route", () => { expect(screen.queryByText("Verified")).toBeNull(); }); - it("renders plugin install counts in the metadata sidebar", async () => { + it("renders plugin activity skeletons while graphs load", async () => { loaderDataMock = { ...loaderDataMock, detail: { @@ -821,15 +830,132 @@ describe("plugin detail route", () => { }; const route = await loadRoute(); const Component = route.__config.component as ComponentType; + const { container } = render(); + + expect(screen.getByText("30-day Downloads")).toBeTruthy(); + expect(screen.queryByText("30-day Installs")).toBeNull(); + expect(container.querySelectorAll(".metric-trend-card-skeleton")).toHaveLength(1); + expect(screen.queryByRole("img", { name: "Daily installs over the last 30 days" })).toBeNull(); + }); + + it("renders canonical topics in the detail hero", async () => { + loaderDataMock = { + ...loaderDataMock, + detail: { + ...loaderDataMock.detail, + package: { + ...loaderDataMock.detail.package!, + topics: ["Web Search", "Research"], + }, + }, + }; + const route = await loadRoute(); + const Component = route.__config.component as ComponentType; render(); - const installsLabel = screen.getByText("Installs"); + expect(screen.getByLabelText("Topics").textContent).toContain("Web Search"); + expect(screen.getByLabelText("Topics").textContent).toContain("Research"); + }); + + it("renders the plugin 30-day downloads graph from a deferred activity query", async () => { + loaderDataMock = { + ...loaderDataMock, + detail: { + package: { + ...loaderDataMock.detail.package!, + latestVersion: "1.0.0", + stats: { downloads: 1_234, installs: 9, stars: 0, versions: 1 }, + }, + owner: null, + }, + }; + convexQueryMock.mockResolvedValueOnce({ + downloads: { + range: "daily", + days: 30, + total: 14, + points: [ + { day: 20_451, value: 2 }, + { day: 20_452, value: 1 }, + { day: 20_453, value: 0 }, + { day: 20_454, value: 5 }, + { day: 20_455, value: 3 }, + { day: 20_456, value: 0 }, + { day: 20_457, value: 3 }, + ], + }, + }); + const route = await loadRoute(); + const Component = route.__config.component as ComponentType; + + render(); + + const downloadsLabel = screen.getByText("30-day Downloads"); const currentVersionLabel = screen.getByText("Current version"); - expect(installsLabel.compareDocumentPosition(currentVersionLabel)).toBe( + expect(downloadsLabel.compareDocumentPosition(currentVersionLabel)).toBe( Node.DOCUMENT_POSITION_FOLLOWING, ); - expect(screen.getByText("9")).toBeTruthy(); + expect(screen.getByText("30-day Downloads")).toBeTruthy(); + expect(screen.queryByRole("img", { name: "Daily downloads over the last 30 days" })).toBeNull(); + await waitFor(() => + expect( + screen.getByRole("img", { name: "Daily downloads over the last 30 days" }), + ).toBeTruthy(), + ); + expect(screen.getByText("14")).toBeTruthy(); + expect(screen.queryByText("30-day Installs")).toBeNull(); + expect(screen.queryByRole("img", { name: "Daily installs over the last 30 days" })).toBeNull(); + expect(screen.getByRole("img", { name: "Daily downloads over the last 30 days" })).toBeTruthy(); + expect(screen.getAllByRole("button", { name: "About activity counts" })).toHaveLength(1); + expect( + convexQueryMock.mock.calls.some(([query, args]) => { + return ( + getFunctionName(query as never) === "packages:getActivityTrendForName" && + typeof args === "object" && + args !== null && + "name" in args && + args.name === "demo-plugin" && + "endDay" in args && + typeof args.endDay === "number" + ); + }), + ).toBe(true); + expect( + useQueryMock.mock.calls.some( + ([query]) => getFunctionName(query as never) === "packages:getActivityTrendForName", + ), + ).toBe(false); + }); + + it("falls back to all-time plugin stats when activity graphs are unavailable", async () => { + loaderDataMock = { + ...loaderDataMock, + detail: { + package: { + ...loaderDataMock.detail.package!, + latestVersion: "1.0.0", + stats: { downloads: 1_234, installs: 9, stars: 0, versions: 1 }, + }, + owner: null, + }, + }; + convexQueryMock.mockResolvedValueOnce(null); + const route = await loadRoute(); + const Component = route.__config.component as ComponentType; + const { container } = render(); + + expect(container.querySelectorAll(".metric-trend-card-skeleton")).toHaveLength(1); + await waitFor(() => + expect(container.querySelectorAll(".metric-trend-card-skeleton")).toHaveLength(0), + ); + + expect(screen.getByText("Downloads")).toBeTruthy(); + expect(screen.getByText("1.2k")).toBeTruthy(); + expect(screen.queryByText("Installs")).toBeNull(); + expect(container.querySelectorAll(".metric-trend-card-skeleton")).toHaveLength(0); + expect(screen.queryByRole("img", { name: "Daily installs over the last 30 days" })).toBeNull(); + expect(screen.queryByRole("img", { name: "Daily downloads over the last 30 days" })).toBeNull(); }); it("shows plugin settings when the viewer can manage the plugin", async () => { @@ -1140,8 +1266,10 @@ describe("plugin detail route", () => { const securityAuditLabelIndex = sidebarLabels.findIndex((label) => label?.startsWith("Security audit"), ); + const downloadsLabelIndex = sidebarLabels.findIndex((label) => label?.includes("Downloads")); expect(securityAuditLabelIndex).toBeGreaterThanOrEqual(0); - expect(securityAuditLabelIndex).toBeGreaterThan(sidebarLabels.indexOf("Installs")); + expect(downloadsLabelIndex).toBeGreaterThanOrEqual(0); + expect(securityAuditLabelIndex).toBeGreaterThan(downloadsLabelIndex); expect(screen.queryByRole("tab", { name: "Capabilities" })).toBeNull(); expect(screen.queryByRole("tab", { name: "Verification" })).toBeNull(); }); diff --git a/src/__tests__/packages-route.test.tsx b/src/__tests__/packages-route.test.tsx index 93327f99..2bbd30a7 100644 --- a/src/__tests__/packages-route.test.tsx +++ b/src/__tests__/packages-route.test.tsx @@ -183,20 +183,52 @@ describe("plugins route", () => { expect(redirectMock).not.toHaveBeenCalled(); }); - it("drops removed downloads sort links to the plugin browse default", async () => { + it("keeps downloads sort links and cursors in filtered plugin browse", async () => { const route = await loadRoute(); const validateSearch = route.__config.validateSearch as ( search: Record, ) => Record; - expect(validateSearch({ sort: "downloads", cursor: "legacy-download-cursor" })).toEqual( + expect( + validateSearch({ category: "security", sort: "downloads", cursor: "download-cursor" }), + ).toEqual( expect.objectContaining({ + category: "security", + sort: "downloads", + cursor: "download-cursor", + }), + ); + }); + + it("drops legacy filtered browse cursors with implicit sort", async () => { + const route = await loadRoute(); + const validateSearch = route.__config.validateSearch as ( + search: Record, + ) => Record; + + expect(validateSearch({ category: "security", cursor: "legacy-install-cursor" })).toEqual( + expect.objectContaining({ + category: "security", sort: undefined, cursor: undefined, }), ); }); + it("drops legacy install sort cursors in plugin browse", async () => { + const route = await loadRoute(); + const validateSearch = route.__config.validateSearch as ( + search: Record, + ) => Record; + + expect(validateSearch({ sort: "installs", cursor: "legacy-install-cursor" })).toEqual( + expect.objectContaining({ + sort: "downloads", + cursor: undefined, + }), + ); + }); + it("redirects search-only sorts back to default when there is no query", async () => { const route = await loadRoute(); const beforeLoad = ( @@ -227,7 +259,7 @@ describe("plugins route", () => { ).not.toThrow(); expect(() => beforeLoad?.({ - search: { q: "security", sort: "installs" }, + search: { q: "security", sort: "downloads" }, }), ).not.toThrow(); expect(() => @@ -402,7 +434,7 @@ describe("plugins route", () => { await loadPluginsPageData({ q: "security", - sort: "installs", + sort: "downloads", cursor: "cursor:search", }); @@ -421,12 +453,12 @@ describe("plugins route", () => { const { loadPluginsPageData } = await import("../routes/plugins/index"); await loadPluginsPageData({ - sort: "installs", + sort: "downloads", }); expect(fetchPluginCatalogMock).toHaveBeenCalledWith( expect.objectContaining({ - sort: "installs", + sort: "downloads", limit: 25, }), ); @@ -515,7 +547,42 @@ describe("plugins route", () => { }); }); - it("renders plugin install counts in browse results", async () => { + it("keeps downloads sort in filtered next-page links", async () => { + searchMock = { category: "security" }; + loaderDataMock = { + items: [ + { + name: "demo-plugin", + displayName: "Demo Plugin", + family: "code-plugin", + channel: "community", + isOfficial: false, + createdAt: 1, + updatedAt: 1, + }, + ], + nextCursor: "cursor:next", + rateLimited: false, + retryAfterSeconds: null, + }; + const route = await loadRoute(); + const Component = route.__config.component as ComponentType; + + render(); + fireEvent.click(screen.getByRole("button", { name: "Next page" })); + + expect(navigateMock).toHaveBeenCalled(); + const lastCall = navigateMock.mock.calls.at(-1)?.[0] as { + search: (prev: Record) => Record; + }; + expect(lastCall.search({ category: "security" })).toEqual({ + category: "security", + cursor: "cursor:next", + sort: "downloads", + }); + }); + + it("renders plugin download counts in browse results", async () => { loaderDataMock = { items: [ { @@ -538,7 +605,7 @@ describe("plugins route", () => { render(); - expect(screen.getByText("9")).toBeTruthy(); + expect(screen.getByText("1.2k")).toBeTruthy(); }); it("renders the browse shell immediately while catalog data loads", async () => { @@ -860,7 +927,7 @@ describe("plugins route", () => { expect(fetchPluginCatalogMock).toHaveBeenCalledWith( expect.objectContaining({ isOfficial: true, - sort: "installs", + sort: "downloads", limit: 25, }), ); @@ -1011,7 +1078,7 @@ describe("plugins route", () => { expect.objectContaining({ sort: "recommended" }), ); expect(validateSearch({ sort: "installs" })).toEqual( - expect.objectContaining({ sort: "installs" }), + expect.objectContaining({ sort: "downloads" }), ); expect(validateSearch({ sort: "relevance" })).toEqual( expect.objectContaining({ sort: "relevance" }), @@ -1228,9 +1295,9 @@ describe("plugins route", () => { render(); - expect(screen.getByRole("radio", { name: "Most installed" }).getAttribute("aria-checked")).toBe( - "true", - ); + expect( + screen.getByRole("radio", { name: "Most downloaded" }).getAttribute("aria-checked"), + ).toBe("true"); expect(screen.getByRole("radio", { name: "Recommended" })).toBeTruthy(); expect(screen.getByRole("radio", { name: "Recently updated" })).toBeTruthy(); expect(screen.queryByRole("radio", { name: "Relevance" })).toBeNull(); @@ -1263,7 +1330,7 @@ describe("plugins route", () => { render(); - fireEvent.click(screen.getByRole("radio", { name: "Most installed" })); + fireEvent.click(screen.getByRole("radio", { name: "Most downloaded" })); const lastCall = navigateMock.mock.calls.at(-1)?.[0] as { search: (prev: Record) => Record; @@ -1273,12 +1340,12 @@ describe("plugins route", () => { cursor: undefined, family: undefined, featured: undefined, - sort: "installs", + sort: "downloads", }); }); it("sorts loaded search results by the selected search sort", async () => { - searchMock = { q: "security", sort: "installs" }; + searchMock = { q: "security", sort: "downloads" }; loaderDataMock = { items: [ { @@ -1311,50 +1378,50 @@ describe("plugins route", () => { render(); - const zulu = screen.getByText("Zulu Plugin"); - const alpha = screen.getByText("Alpha Plugin"); - expect(zulu.compareDocumentPosition(alpha) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); - }); - - it("sorts loaded search results by install count", async () => { - searchMock = { q: "security", sort: "installs" }; - loaderDataMock = { - items: [ - { - name: "zulu-plugin", - displayName: "Zulu Plugin", - family: "code-plugin", - channel: "community", - isOfficial: false, - createdAt: 2, - updatedAt: 20, - stats: { downloads: 10, installs: 1, stars: 0, versions: 1 }, - }, - { - name: "alpha-plugin", - displayName: "Alpha Plugin", - family: "code-plugin", - channel: "community", - isOfficial: false, - createdAt: 1, - updatedAt: 10, - stats: { downloads: 1, installs: 10, stars: 0, versions: 1 }, - }, - ], - nextCursor: null, - rateLimited: false, - retryAfterSeconds: null, - }; - const route = await loadRoute(); - const Component = route.__config.component as ComponentType; - - render(); - const alpha = screen.getByText("Alpha Plugin"); const zulu = screen.getByText("Zulu Plugin"); expect(alpha.compareDocumentPosition(zulu) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); }); + it("sorts loaded search results by download count", async () => { + searchMock = { q: "security", sort: "downloads" }; + loaderDataMock = { + items: [ + { + name: "zulu-plugin", + displayName: "Zulu Plugin", + family: "code-plugin", + channel: "community", + isOfficial: false, + createdAt: 2, + updatedAt: 20, + stats: { downloads: 10, installs: 1, stars: 0, versions: 1 }, + }, + { + name: "alpha-plugin", + displayName: "Alpha Plugin", + family: "code-plugin", + channel: "community", + isOfficial: false, + createdAt: 1, + updatedAt: 10, + stats: { downloads: 1, installs: 10, stars: 0, versions: 1 }, + }, + ], + nextCursor: null, + rateLimited: false, + retryAfterSeconds: null, + }; + const route = await loadRoute(); + const Component = route.__config.component as ComponentType; + + render(); + + const zulu = screen.getByText("Zulu Plugin"); + const alpha = screen.getByText("Alpha Plugin"); + expect(zulu.compareDocumentPosition(alpha) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + }); + it("keeps search sort visible even if a stale featured flag is present", async () => { searchMock = { q: "security", featured: true }; const route = await loadRoute(); @@ -1379,8 +1446,8 @@ describe("plugins route", () => { const sortOptions = Array.from( screen.getByRole("radiogroup", { name: "Sort order" }).querySelectorAll('[role="radio"]'), ).map((option) => option.textContent); - expect(sortOptions).toEqual(["Recommended", "Most installed", "Recently updated"]); - expect(screen.queryByRole("radio", { name: "Most downloaded" })).toBeNull(); + expect(sortOptions).toEqual(["Recommended", "Most downloaded", "Recently updated"]); + expect(screen.queryByRole("radio", { name: "Most installed" })).toBeNull(); expect(screen.queryByRole("radio", { name: "Newest" })).toBeNull(); expect(screen.queryByRole("radio", { name: "Name" })).toBeNull(); }); diff --git a/src/__tests__/skill-detail-page.test.tsx b/src/__tests__/skill-detail-page.test.tsx index 15e8df64..25267335 100644 --- a/src/__tests__/skill-detail-page.test.tsx +++ b/src/__tests__/skill-detail-page.test.tsx @@ -2,7 +2,7 @@ import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { getFunctionName } from "convex/server"; import type { ReactNode } from "react"; import { toast } from "sonner"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { Id } from "../../convex/_generated/dataModel"; import { SkillDetailPage } from "../components/SkillDetailPage"; @@ -38,10 +38,13 @@ vi.mock("@convex-dev/auth/react", () => ({ const useQueryMock = vi.fn(); const useMutationMock = vi.fn(); +const convexQueryMock = vi.fn(); +const convexClientMock = { query: convexQueryMock }; const getReadmeMock = vi.fn(); vi.mock("convex/react", () => ({ ConvexReactClient: class {}, + useConvex: () => convexClientMock, useQuery: (...args: unknown[]) => useQueryMock(...args), useMutation: (...args: unknown[]) => useMutationMock(...args), useAction: () => getReadmeMock, @@ -76,6 +79,7 @@ describe("SkillDetailPage", () => { window.location.hash = ""; useQueryMock.mockReset(); useMutationMock.mockReset(); + convexQueryMock.mockReset(); getReadmeMock.mockReset(); navigateMock.mockReset(); routerInvalidateMock.mockReset(); @@ -92,6 +96,11 @@ describe("SkillDetailPage", () => { if (args === "skip") return undefined; return undefined; }); + convexQueryMock.mockResolvedValue(null); + }); + + afterEach(() => { + vi.useRealTimers(); }); it("shows a loading indicator while loading", () => { @@ -181,6 +190,208 @@ describe("SkillDetailPage", () => { expect(screen.queryByRole("button", { name: "Compare" })).toBeNull(); }); + it("loads skill activity graphs through a deferred one-shot query", async () => { + const activityTrend = { + installs: { + range: "daily" as const, + days: 30, + total: 5, + points: [ + { day: 20_451, value: 1 }, + { day: 20_452, value: 0 }, + { day: 20_453, value: 2 }, + { day: 20_454, value: 1 }, + { day: 20_455, value: 1 }, + ], + }, + downloads: { + range: "daily" as const, + days: 30, + total: 12, + points: [ + { day: 20_451, value: 1 }, + { day: 20_452, value: 0 }, + { day: 20_453, value: 4 }, + { day: 20_454, value: 3 }, + { day: 20_455, value: 4 }, + ], + }, + }; + const initialData = { + result: { + skill: { + _id: skillId, + _creationTime: 0, + slug: "weather", + displayName: "Weather", + summary: "Get current weather.", + ownerUserId: ownerId, + ownerPublisherId, + tags: {}, + badges: {}, + stats: { + stars: 12, + downloads: 34, + installsCurrent: 5, + installsAllTime: 8, + versions: 1, + comments: 0, + }, + createdAt: 0, + updatedAt: 0, + }, + owner: { + _id: ownerPublisherId, + _creationTime: 0, + kind: "user" as const, + handle: "steipete", + displayName: "Peter", + linkedUserId: ownerId, + }, + latestVersion: { + _id: versionId, + _creationTime: 0, + skillId, + version: "1.0.0", + fingerprint: "abc", + changelog: "Initial release", + parsed: { license: "MIT-0" as const, frontmatter: {} }, + files: [ + { + path: "SKILL.md", + size: 10, + storageId, + sha256: "abc", + contentType: "text/markdown", + }, + ], + createdBy: ownerId, + createdAt: 0, + }, + forkOf: null, + canonical: null, + }, + readme: "# Weather", + readmeError: null, + }; + convexQueryMock.mockResolvedValueOnce(activityTrend); + + const { container, rerender } = render( + , + ); + + expect(screen.getByRole("tab", { name: "Files" })).toBeTruthy(); + expect(screen.getByText("30-day Downloads")).toBeTruthy(); + expect(screen.queryByText("30-day Installs")).toBeNull(); + expect(container.querySelectorAll(".metric-trend-card-skeleton")).toHaveLength(1); + expect(screen.queryByRole("img", { name: "Daily installs over the last 30 days" })).toBeNull(); + expect( + useQueryMock.mock.calls.some( + ([query]) => getFunctionName(query as never) === "skills:getActivityTrendForSlug", + ), + ).toBe(false); + + await waitFor(() => expect(convexQueryMock).toHaveBeenCalled()); + rerender(); + + expect(screen.queryByRole("img", { name: "Daily installs over the last 30 days" })).toBeNull(); + expect(screen.getByRole("img", { name: "Daily downloads over the last 30 days" })).toBeTruthy(); + expect(container.querySelectorAll(".metric-trend-card-skeleton")).toHaveLength(0); + expect( + convexQueryMock.mock.calls.some((call) => { + const query = call[0]; + const args = call[1]; + return ( + getFunctionName(query as never) === "skills:getActivityTrendForSlug" && + typeof args === "object" && + args !== null && + "slug" in args && + args.slug === "weather" && + "endDay" in args && + typeof args.endDay === "number" && + "ownerHandle" in args && + args.ownerHandle === "steipete" + ); + }), + ).toBe(true); + }); + + it("passes the loader owner id to deferred activity trends when no public owner handle is available", async () => { + const initialData = { + result: { + skill: { + _id: skillId, + _creationTime: 0, + slug: "weather", + displayName: "Weather", + summary: "Get current weather.", + ownerUserId: ownerId, + ownerPublisherId, + tags: {}, + badges: {}, + stats: { + stars: 12, + downloads: 34, + installsCurrent: 5, + installsAllTime: 8, + versions: 1, + comments: 0, + }, + createdAt: 0, + updatedAt: 0, + }, + owner: null, + latestVersion: { + _id: versionId, + _creationTime: 0, + skillId, + version: "1.0.0", + fingerprint: "abc", + changelog: "Initial release", + parsed: { license: "MIT-0" as const, frontmatter: {} }, + files: [ + { + path: "SKILL.md", + size: 10, + storageId, + sha256: "abc", + contentType: "text/markdown", + }, + ], + createdBy: ownerId, + createdAt: 0, + }, + forkOf: null, + canonical: null, + }, + readme: "# Weather", + readmeError: null, + lookupOwnerHandle: "users:1", + }; + + render(); + + await waitFor(() => expect(convexQueryMock).toHaveBeenCalled()); + + expect( + convexQueryMock.mock.calls.some((call) => { + const query = call[0]; + const args = call[1]; + return ( + getFunctionName(query as never) === "skills:getActivityTrendForSlug" && + typeof args === "object" && + args !== null && + "slug" in args && + args.slug === "weather" && + "endDay" in args && + typeof args.endDay === "number" && + "ownerHandle" in args && + args.ownerHandle === "users:1" + ); + }), + ).toBe(true); + }); + it("does not spin forever when a source-backed skill has no stored version", async () => { useQueryMock.mockImplementation((_fn: unknown, args: unknown) => { if (args === "skip") return undefined; diff --git a/src/__tests__/skills-index.test.tsx b/src/__tests__/skills-index.test.tsx index be30b2a7..339b2d6d 100644 --- a/src/__tests__/skills-index.test.tsx +++ b/src/__tests__/skills-index.test.tsx @@ -92,12 +92,12 @@ describe("SkillsIndex", () => { expect(sortOptions.slice(0, 2)).toEqual(["Recommended", "Featured"]); }); - it("offers installs without exposing downloads as a browse sort", async () => { + it("offers downloads as a browse sort", async () => { render(); await act(async () => {}); - expect(screen.getByRole("radio", { name: "Most installed" })).toBeTruthy(); - expect(screen.queryByRole("radio", { name: "Most downloaded" })).toBeNull(); + expect(screen.getByRole("radio", { name: "Most downloaded" })).toBeTruthy(); + expect(screen.queryByRole("radio", { name: "Most installed" })).toBeNull(); }); it("renders an empty state when no skills are returned", async () => { @@ -427,8 +427,8 @@ describe("SkillsIndex", () => { }); }); - it("preserves explicitly user-set installs sort when entering search", async () => { - searchMock = { sort: "installs", dir: "desc" }; + it("preserves explicitly user-set downloads sort when entering search", async () => { + searchMock = { sort: "downloads", dir: "desc" }; vi.useFakeTimers(); render(); @@ -445,9 +445,9 @@ describe("SkillsIndex", () => { search: (prev: Record) => Record; }; expect(lastCall.replace).toBe(true); - expect(lastCall.search({ sort: "installs", dir: "desc" })).toEqual({ + expect(lastCall.search({ sort: "downloads", dir: "desc" })).toEqual({ q: "cli-design-framework", - sort: "installs", + sort: "downloads", dir: "desc", }); }); @@ -477,12 +477,12 @@ describe("SkillsIndex", () => { searchMock = { sort: "recommended", dir: "asc" }; render(); - fireEvent.click(screen.getByRole("radio", { name: "Most installed" })); + fireEvent.click(screen.getByRole("radio", { name: "Most downloaded" })); const lastCall = getLastNavigateCall(); expect(lastCall.replace).toBe(true); expect(lastCall.search({ sort: "recommended", dir: "asc" })).toEqual({ - sort: "installs", + sort: "downloads", dir: "desc", }); }); @@ -491,26 +491,26 @@ describe("SkillsIndex", () => { searchMock = { q: "notion", sort: "relevance", dir: "asc" }; render(); - fireEvent.click(screen.getByRole("radio", { name: "Most installed" })); + fireEvent.click(screen.getByRole("radio", { name: "Most downloaded" })); const lastCall = getLastNavigateCall(); expect(lastCall.replace).toBe(true); expect(lastCall.search({ q: "notion", sort: "relevance", dir: "asc" })).toEqual({ q: "notion", - sort: "installs", + sort: "downloads", dir: "desc", }); }); it("clears direction when returning to recommended browse sort", async () => { - searchMock = { sort: "installs", dir: "asc" }; + searchMock = { sort: "downloads", dir: "asc" }; render(); fireEvent.click(screen.getByRole("radio", { name: "Recommended" })); const lastCall = getLastNavigateCall(); expect(lastCall.replace).toBe(true); - expect(lastCall.search({ sort: "installs", dir: "asc" })).toEqual({ + expect(lastCall.search({ sort: "downloads", dir: "asc" })).toEqual({ sort: "recommended", dir: undefined, }); diff --git a/src/__tests__/user-profile-route.test.tsx b/src/__tests__/user-profile-route.test.tsx index 2de5ae00..fbec5d47 100644 --- a/src/__tests__/user-profile-route.test.tsx +++ b/src/__tests__/user-profile-route.test.tsx @@ -46,7 +46,7 @@ const publisher = { official: true, publishedItems: [], stats: { - downloads: 0, + downloads: 42, installs: 27, packages: 0, skills: 136, @@ -73,19 +73,19 @@ describe("user profile route", () => { }); }); - it("shows total installs instead of downloads in the publisher header", async () => { + it("shows total downloads in the publisher header", async () => { const route = await loadRoute(); const Component = route.__config.component as ComponentType; render(); const stats = screen.getByLabelText("Publisher stats"); - expect(within(stats).getByText("27")).toBeTruthy(); - expect(within(stats).getByText("installs")).toBeTruthy(); - expect(within(stats).queryByText("downloads")).toBeNull(); + expect(within(stats).getByText("42")).toBeTruthy(); + expect(within(stats).getByText("downloads")).toBeTruthy(); + expect(within(stats).queryByText("installs")).toBeNull(); }); - it("uses the legacy sort alias while the backend rollout remains compatible", async () => { + it("uses downloads sort for published catalog pages", async () => { const route = await loadRoute(); const Component = route.__config.component as ComponentType; diff --git a/src/components/ActivityMetricLabel.tsx b/src/components/ActivityMetricLabel.tsx new file mode 100644 index 00000000..c7a907b6 --- /dev/null +++ b/src/components/ActivityMetricLabel.tsx @@ -0,0 +1,29 @@ +import { Info } from "lucide-react"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./ui/tooltip"; + +const DOWNLOAD_COUNT_HELP = + "Download counts can be inflated by bots or spam. Use them as context only, not as a quality or trust signal."; + +export function ActivityMetricLabel({ label }: { label: string }) { + return ( + + {label} + + + + + + + {DOWNLOAD_COUNT_HELP} + + + + + ); +} diff --git a/src/components/HomeListingSection.tsx b/src/components/HomeListingSection.tsx index 4bb7a162..bdedca7f 100644 --- a/src/components/HomeListingSection.tsx +++ b/src/components/HomeListingSection.tsx @@ -145,7 +145,7 @@ function sortSkillEntries(entries: SkillPageEntry[], tab: ListingTab) { (left.skill.updatedAt ?? left.skill.createdAt ?? left.skill._creationTime ?? 0) ); } - return (right.skill.stats?.installsAllTime ?? 0) - (left.skill.stats?.installsAllTime ?? 0); + return (right.skill.stats?.downloads ?? 0) - (left.skill.stats?.downloads ?? 0); }); } @@ -270,7 +270,7 @@ async function fetchSkillListing( const result = await convexHttp.query(api.skills.listPublicPageV4, { cursor: cursor ?? undefined, numItems: numItems - page.length, - sort: tab === "new" ? "newest" : "installs", + sort: tab === "new" ? "newest" : "downloads", dir: "desc", officialFirst: tab === "officials" ? true : undefined, categorySlug: categorySlug ?? undefined, @@ -320,7 +320,7 @@ async function fetchPluginListing( cursor: cursor ?? undefined, isOfficial: openClawOfficials ? true : undefined, excludedScanStatuses: tab === "new" ? ["pending", "suspicious"] : undefined, - sort: tab === "new" ? "updated" : "installs", + sort: tab === "new" ? "updated" : "downloads", limit: Math.min(limit - items.length, PLUGIN_CATALOG_PAGE_LIMIT), signal, }); @@ -339,7 +339,7 @@ async function fetchPluginListing( if (tab === "new") { items.sort((a, b) => b.updatedAt - a.updatedAt); } else if (tab === "popular" || openClawOfficials) { - items.sort((a, b) => (b.stats?.installs ?? 0) - (a.stats?.installs ?? 0)); + items.sort((a, b) => (b.stats?.downloads ?? 0) - (a.stats?.downloads ?? 0)); } const page = items.slice(0, limit); return { @@ -373,7 +373,7 @@ function HomeListingSkillRow({ entry, showStats }: { entry: SkillPageEntry; show
) : null} @@ -404,7 +404,7 @@ function HomeListingPluginRow({ plugin }: { plugin: PackageListItem }) {
@@ -436,7 +436,7 @@ function HomeListingSkillCard({ entry, showStats }: { entry: SkillPageEntry; sho
) : null} @@ -469,7 +469,7 @@ function HomeListingPluginCard({ plugin }: { plugin: PackageListItem }) {
@@ -678,7 +678,7 @@ export function HomeListingSection() { category: categorySlug ?? undefined, isOfficial: tab === "officials" ? true : undefined, excludedScanStatuses: tab === "new" ? ["pending", "suspicious"] : undefined, - sort: tab === "new" ? "updated" : "installs", + sort: tab === "new" ? "updated" : "downloads", limit: fetchLimit, signal: controller.signal, }), diff --git a/src/components/MetricTrendCard.tsx b/src/components/MetricTrendCard.tsx new file mode 100644 index 00000000..39cd3019 --- /dev/null +++ b/src/components/MetricTrendCard.tsx @@ -0,0 +1,155 @@ +import { useId, useMemo, useState, type MouseEvent, type PointerEvent } from "react"; +import type { MetricTrend, MetricTrendPoint } from "../lib/activityTrend"; +import { formatCompactStat } from "../lib/numberFormat"; + +function clampPointValue(value: number) { + if (!Number.isFinite(value) || value < 0) return 0; + return value; +} + +function formatActivityDate(day: number) { + return new Intl.DateTimeFormat("en", { + month: "short", + day: "numeric", + timeZone: "UTC", + }).format(new Date(day * 86_400_000)); +} + +function formatActivityValue(value: number, unitLabel: string) { + const rounded = Math.max(0, Math.floor(value)); + return `${formatCompactStat(rounded)} ${unitLabel}${rounded === 1 ? "" : "s"}`; +} + +function buildSparkline(points: MetricTrendPoint[]) { + const values = points.map((point) => clampPointValue(point.value)); + const max = Math.max(1, ...values); + const width = 100; + const height = 34; + const topPad = 3; + const bottomPad = 5; + const chartHeight = height - topPad - bottomPad; + const divisor = Math.max(1, values.length - 1); + const coords = values.map((value, index) => { + const x = (index / divisor) * width; + const y = topPad + chartHeight - (value / max) * chartHeight; + return { x, y }; + }); + const line = coords.map(({ x, y }) => `${x.toFixed(2)},${y.toFixed(2)}`).join(" "); + const area = + coords.length > 0 + ? `M 0 ${height} L ${coords + .map(({ x, y }) => `${x.toFixed(2)} ${y.toFixed(2)}`) + .join(" L ")} L ${width} ${height} Z` + : ""; + return { line, area, coords }; +} + +function getNearestPointIndex(params: { + clientX: number; + left: number; + width: number; + pointCount: number; +}) { + if (params.pointCount <= 1 || params.width <= 0) return 0; + const ratio = Math.min(1, Math.max(0, (params.clientX - params.left) / params.width)); + return Math.round(ratio * (params.pointCount - 1)); +} + +export function MetricTrendCard({ + trend, + ariaLabel, + unitLabel, +}: { + trend: MetricTrend; + ariaLabel: string; + unitLabel: "download" | "install"; +}) { + const descriptionId = useId(); + const [activeIndex, setActiveIndex] = useState(null); + const chart = useMemo(() => buildSparkline(trend.points), [trend.points]); + const activePoint = + activeIndex !== null && activeIndex >= 0 && activeIndex < trend.points.length + ? trend.points[activeIndex] + : null; + const activeCoord = + activeIndex !== null && activeIndex >= 0 && activeIndex < chart.coords.length + ? chart.coords[activeIndex] + : null; + const activeLabel = activePoint + ? `${formatActivityDate(activePoint.day)} · ${formatActivityValue(activePoint.value, unitLabel)}` + : `${trend.days} days`; + + function showNearestPoint(event: PointerEvent | MouseEvent) { + const rect = event.currentTarget.getBoundingClientRect(); + setActiveIndex( + getNearestPointIndex({ + clientX: event.clientX, + left: rect.left, + width: rect.width, + pointCount: trend.points.length, + }), + ); + } + + function showLatestPoint() { + setActiveIndex(Math.max(0, trend.points.length - 1)); + } + + return ( +
+
+ {formatCompactStat(trend.total)} + + {activeLabel} + +
+ setActiveIndex(null)} + onMouseEnter={showNearestPoint} + onMouseMove={showNearestPoint} + onPointerEnter={showNearestPoint} + onPointerMove={showNearestPoint} + onPointerLeave={() => setActiveIndex(null)} + > + + + {activeCoord ? ( + + ) : null} + +
+ ); +} + +export function MetricTrendCardSkeleton() { + return ( + diff --git a/src/components/SkillDetailPage.tsx b/src/components/SkillDetailPage.tsx index c4ad6c0f..9ef024de 100644 --- a/src/components/SkillDetailPage.tsx +++ b/src/components/SkillDetailPage.tsx @@ -11,6 +11,7 @@ import { useEffect, useMemo, useState } from "react"; import { toast } from "sonner"; import { api } from "../../convex/_generated/api"; import type { Doc, Id } from "../../convex/_generated/dataModel"; +import { getActivityTrendEndDay } from "../lib/activityTrend"; import { getUserFacingAuthError, isBannedAccountAuthError, @@ -24,6 +25,7 @@ import type { SkillBySlugResult, SkillPageInitialData } from "../lib/skillPage"; import { resolveGitHubSkillReadmeHref } from "../lib/skillReadmeLinks"; import { clearAuthError, setAuthError } from "../lib/useAuthError"; import { useAuthStatus } from "../lib/useAuthStatus"; +import { useDeferredSkillActivityTrend } from "../lib/useDeferredActivityTrend"; import { DetailBody, DetailPageShell } from "./DetailPageShell"; import { DetailSecuritySummary } from "./DetailSecuritySummary"; import { GenericNotFoundPage } from "./GenericNotFoundPage"; @@ -377,8 +379,20 @@ export function SkillDetailPage({ ...(ownerHandle ? { ownerHandle } : {}), }).toString()}` : null; + const activityTrendOwnerHandle = + ownerHandle ?? liveLookupOwnerHandle ?? (owner?._id ? String(owner._id) : null); + const activityTrendEndDay = getActivityTrendEndDay(); const canonicalOwnerParam = typeof canonicalOwner === "string" ? canonicalOwner.trim().toLowerCase() : null; + const { trend: activityTrend, loading: activityTrendLoading } = useDeferredSkillActivityTrend( + skill + ? { + slug: skill.slug, + endDay: activityTrendEndDay, + ...(activityTrendOwnerHandle ? { ownerHandle: activityTrendOwnerHandle } : {}), + } + : null, + ); const wantsCanonicalRedirect = Boolean( ownerParam && ((result?.resolvedSlug && result.resolvedSlug !== slug) || @@ -897,6 +911,8 @@ export function SkillDetailPage({ category={relatedCategory} priorityContent={staffVisibilityAlert} securityAuditSummary={securitySummary} + activityTrend={activityTrend} + activityTrendLoading={activityTrendLoading} newVersionHref={newVersionHref} settingsHref={settingsHref} showArchiveMetadata={!isGitHubBackedSkill} diff --git a/src/components/SkillHeader.test.tsx b/src/components/SkillHeader.test.tsx index 40f82944..d7f971e3 100644 --- a/src/components/SkillHeader.test.tsx +++ b/src/components/SkillHeader.test.tsx @@ -120,14 +120,88 @@ describe("SkillHeader", () => { expect(onToggleStar).not.toHaveBeenCalled(); expect(onOpenReport).not.toHaveBeenCalled(); expect(screen.getByText("Owner")).toBeTruthy(); - expect(screen.getByText("Installs")).toBeTruthy(); - expect(screen.getByText("3")).toBeTruthy(); + expect(screen.getByText("Downloads")).toBeTruthy(); + expect(screen.getByText("2")).toBeTruthy(); expect(container.querySelector('a[href="/user/local"]')).toBeTruthy(); expect( container.querySelector('nav[aria-label="Skill breadcrumbs"] a[href="/user/local"]'), ).toBeTruthy(); }); + it("shows the 30-day downloads graph from activity data", () => { + renderHeader({ + activityTrend: { + downloads: { + range: "daily", + days: 30, + total: 12, + points: [ + { day: 20_451, value: 1 }, + { day: 20_452, value: 0 }, + { day: 20_453, value: 4 }, + { day: 20_454, value: 2 }, + { day: 20_455, value: 0 }, + { day: 20_456, value: 3 }, + { day: 20_457, value: 2 }, + ], + }, + }, + }); + + expect(screen.getByText("30-day Downloads")).toBeTruthy(); + expect(screen.getByText("12")).toBeTruthy(); + expect(screen.queryByText("30-day Installs")).toBeNull(); + expect(screen.queryByText("5")).toBeNull(); + expect(screen.queryByRole("img", { name: "Daily installs over the last 30 days" })).toBeNull(); + expect(screen.getByRole("img", { name: "Daily downloads over the last 30 days" })).toBeTruthy(); + expect(screen.getAllByRole("button", { name: "About activity counts" })).toHaveLength(1); + }); + + it("reserves graph space while activity metrics are loading", () => { + const { container } = renderHeader({ activityTrendLoading: true }); + + expect(screen.getByText("30-day Downloads")).toBeTruthy(); + expect(screen.queryByText("30-day Installs")).toBeNull(); + expect(container.querySelectorAll(".metric-trend-card-skeleton")).toHaveLength(1); + expect(screen.queryByRole("img", { name: "Daily installs over the last 30 days" })).toBeNull(); + }); + + it("shows the nearest daily download graph point and line marker on hover", () => { + const { container } = renderHeader({ + activityTrend: { + downloads: { + range: "daily", + days: 30, + total: 12, + points: [ + { day: 20_451, value: 1 }, + { day: 20_452, value: 0 }, + { day: 20_453, value: 11 }, + ], + }, + }, + }); + + const chart = screen.getByRole("img", { name: "Daily downloads over the last 30 days" }); + chart.getBoundingClientRect = () => + ({ + x: 0, + y: 0, + left: 0, + top: 0, + right: 100, + bottom: 34, + width: 100, + height: 34, + toJSON: () => ({}), + }) satisfies DOMRect; + + fireEvent.pointerMove(chart, { clientX: 100 }); + + expect(screen.getByText(/11 downloads$/)).toBeTruthy(); + expect(container.querySelectorAll(".metric-trend-marker-line")).toHaveLength(1); + }); + it("shows the Official tag in the title for official owner skills", () => { const { container } = renderHeader({ owner: { @@ -185,7 +259,7 @@ describe("SkillHeader", () => { it("hides archive-only metadata for source-backed skills", () => { renderHeader({ showArchiveMetadata: false }); - expect(screen.getByText("Installs")).toBeTruthy(); + expect(screen.getByText("Downloads")).toBeTruthy(); expect(screen.getByText("Owner")).toBeTruthy(); expect(screen.getByText("Last updated")).toBeTruthy(); expect(screen.queryByText("Current version")).toBeNull(); diff --git a/src/components/SkillHeader.tsx b/src/components/SkillHeader.tsx index 4f067294..6f0f7da2 100644 --- a/src/components/SkillHeader.tsx +++ b/src/components/SkillHeader.tsx @@ -4,15 +4,18 @@ import { PLATFORM_SKILL_LICENSE } from "clawhub-schema/licenseConstants"; import { Download, Flag, Settings, ShieldCheck, Star, Upload } from "lucide-react"; import type { ReactNode } from "react"; import type { Doc, Id } from "../../convex/_generated/dataModel"; +import type { ActivityTrend } from "../lib/activityTrend"; import { getSkillBadges } from "../lib/badges"; import { buildSkillCategoryBrowseHref, type SkillCategory } from "../lib/categories"; import { formatSkillStatsTriplet } from "../lib/numberFormat"; import type { PublicPublisher, PublicSkill } from "../lib/publicUser"; import { getRuntimeEnv } from "../lib/runtimeEnv"; import { timeAgo } from "../lib/timeAgo"; +import { ActivityMetricLabel } from "./ActivityMetricLabel"; import { CatalogTopicList } from "./CatalogTopicList"; import { DetailHero } from "./DetailPageShell"; import { DetailSecuritySummaryLabel } from "./DetailSecuritySummary"; +import { MetricTrendCard, MetricTrendCardSkeleton } from "./MetricTrendCard"; import { OfficialTag } from "./OfficialBadge"; import { SidebarMetadata } from "./SidebarMetadata"; import { buildSkillHref } from "./skillDetailUtils"; @@ -117,6 +120,8 @@ type SkillHeaderProps = { priorityContent?: ReactNode; postInstallContent?: ReactNode; securityAuditSummary?: ReactNode; + activityTrend?: ActivityTrend | null; + activityTrendLoading?: boolean; newVersionHref?: string | null; settingsHref?: string | null; showArchiveMetadata?: boolean; @@ -152,6 +157,8 @@ export function SkillHeader({ priorityContent, postInstallContent, securityAuditSummary, + activityTrend, + activityTrendLoading = false, newVersionHref, settingsHref, showArchiveMetadata = true, @@ -221,6 +228,8 @@ export function SkillHeader({ latestVersion={latestVersion} showArchiveMetadata={showArchiveMetadata} securityAuditSummary={securityAuditSummary} + activityTrend={activityTrend} + activityTrendLoading={activityTrendLoading} /> {hasSidebarActions ? (
@@ -469,6 +478,8 @@ function SkillSidebarStats({ latestVersion, showArchiveMetadata, securityAuditSummary, + activityTrend, + activityTrendLoading = false, }: { skill: Doc<"skills"> | PublicSkill; owner: PublicPublisher | null; @@ -477,6 +488,8 @@ function SkillSidebarStats({ latestVersion: SkillHeaderLatestVersion; showArchiveMetadata: boolean; securityAuditSummary?: ReactNode; + activityTrend?: ActivityTrend | null; + activityTrendLoading?: boolean; }) { const githubRepositoryLink = getGitHubRepositoryLink(skill); @@ -485,7 +498,31 @@ function SkillSidebarStats({ ariaLabel="Skill metadata" density="compact" blocks={[ - { label: "Installs", value: formattedStats.installsAllTime, large: true }, + activityTrendLoading + ? { + key: "download-trend-loading", + label: , + value: , + large: true, + } + : activityTrend + ? { + key: "download-trend", + label: , + value: ( + + ), + large: true, + } + : { + label: , + value: formattedStats.downloads, + large: true, + }, { label: "Repository", value: githubRepositoryLink }, { label: "Owner", diff --git a/src/components/SkillListItem.tsx b/src/components/SkillListItem.tsx index 4980e7e2..6edb2b6f 100644 --- a/src/components/SkillListItem.tsx +++ b/src/components/SkillListItem.tsx @@ -1,5 +1,5 @@ import { Link } from "@tanstack/react-router"; -import { PackageCheck, Star } from "lucide-react"; +import { Download, Star } from "lucide-react"; import { getSkillBadges } from "../lib/badges"; import { formatCompactStat } from "../lib/numberFormat"; import type { PublicPublisher, PublicSkill } from "../lib/publicUser"; @@ -51,8 +51,7 @@ export function SkillListItem({ skill, ownerHandle, owner }: SkillListItemProps)
diff --git a/src/components/SkillStats.tsx b/src/components/SkillStats.tsx index b01155a0..421a324a 100644 --- a/src/components/SkillStats.tsx +++ b/src/components/SkillStats.tsx @@ -1,4 +1,4 @@ -import { PackageCheck, Star } from "lucide-react"; +import { Download, Star } from "lucide-react"; import { formatSkillStatsTriplet, type SkillStatsTriplet } from "../lib/numberFormat"; export function SkillStatsTripletLine({ stats }: { stats: SkillStatsTriplet }) { @@ -11,8 +11,8 @@ export function SkillStatsTripletLine({ stats }: { stats: SkillStatsTriplet }) { · - ); diff --git a/src/components/UserBadge.test.tsx b/src/components/UserBadge.test.tsx index caf0bb05..b861d9b9 100644 --- a/src/components/UserBadge.test.tsx +++ b/src/components/UserBadge.test.tsx @@ -5,7 +5,7 @@ import { describe, expect, it } from "vitest"; import type { Id } from "../../convex/_generated/dataModel"; import type { PublicPublisher, PublicUser } from "../lib/publicUser"; import { TooltipProvider } from "./ui/tooltip"; -import { getHoverTotalInstalls, UserBadge } from "./UserBadge"; +import { getHoverTotalDownloads, UserBadge } from "./UserBadge"; describe("UserBadge", () => { const user: PublicUser = { @@ -81,10 +81,10 @@ describe("UserBadge", () => { it("falls back to the legacy hover metric during rollout", () => { expect( - getHoverTotalInstalls({ + getHoverTotalDownloads({ publishedSkills: 1, totalStars: 2, - totalDownloads: 42, + totalInstalls: 42, }), ).toBe(42); }); diff --git a/src/components/UserBadge.tsx b/src/components/UserBadge.tsx index 34a24807..af5d6e16 100644 --- a/src/components/UserBadge.tsx +++ b/src/components/UserBadge.tsx @@ -1,4 +1,4 @@ -import { Package, PackageCheck, Star } from "lucide-react"; +import { Download, Package, Star } from "lucide-react"; import { useEffect, useState } from "react"; import { api } from "../../convex/_generated/api"; import type { Id } from "../../convex/_generated/dataModel"; @@ -110,8 +110,8 @@ type HoverStats = { totalDownloads?: number; }; -export function getHoverTotalInstalls(stats: HoverStats) { - return stats.totalInstalls ?? stats.totalDownloads ?? 0; +export function getHoverTotalDownloads(stats: HoverStats) { + return stats.totalDownloads ?? stats.totalInstalls ?? 0; } function UserStatsTooltipContent({ @@ -171,10 +171,10 @@ function UserStatsTooltipContent({ - - {formatCompactStat(getHoverTotalInstalls(stats))} + + {formatCompactStat(getHoverTotalDownloads(stats))} )} diff --git a/src/lib/activityTrend.ts b/src/lib/activityTrend.ts new file mode 100644 index 00000000..6f4897b2 --- /dev/null +++ b/src/lib/activityTrend.ts @@ -0,0 +1,49 @@ +import { api } from "../../convex/_generated/api"; + +export type MetricTrendPoint = { + day: number; + value: number; +}; + +export type MetricTrend = { + range: "daily"; + days: number; + total: number; + points: MetricTrendPoint[]; +}; + +export type ActivityTrend = { + downloads: MetricTrend; +}; + +export const getSkillActivityTrendForSlug = api.skills.getActivityTrendForSlug; +export const getPackageActivityTrendForName = api.packages.getActivityTrendForName; + +const DAY_MS = 86_400_000; + +export function getActivityTrendEndDay(now = Date.now()) { + return Math.floor(now / DAY_MS); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function isMetricTrend(value: unknown): value is MetricTrend { + if (!isRecord(value)) return false; + if ( + value.range !== "daily" || + typeof value.days !== "number" || + typeof value.total !== "number" + ) { + return false; + } + if (!Array.isArray(value.points)) return false; + return value.points.every( + (point) => isRecord(point) && typeof point.day === "number" && typeof point.value === "number", + ); +} + +export function isActivityTrend(value: unknown): value is ActivityTrend { + return isRecord(value) && isMetricTrend(value.downloads); +} diff --git a/src/lib/packageApi.test.ts b/src/lib/packageApi.test.ts index cbed3efb..7089256f 100644 --- a/src/lib/packageApi.test.ts +++ b/src/lib/packageApi.test.ts @@ -646,13 +646,13 @@ describe("fetchPluginCatalog", () => { expect(url.searchParams.get("sort")).toBe("updated"); await fetchPluginCatalog({ - sort: "installs", + sort: "downloads", limit: 20, }); - const installsUrl = new URL(fetchMock.mock.calls[1]?.[0] as string); - expect(installsUrl.pathname).toBe("/api/v1/plugins"); - expect(installsUrl.searchParams.get("sort")).toBe("installs"); + const downloadsUrl = new URL(fetchMock.mock.calls[1]?.[0] as string); + expect(downloadsUrl.pathname).toBe("/api/v1/plugins"); + expect(downloadsUrl.searchParams.get("sort")).toBe("downloads"); }); it("uses the dedicated plugins search endpoint for search mode", async () => { diff --git a/src/lib/packageApi.ts b/src/lib/packageApi.ts index 78b5f17f..b176897d 100644 --- a/src/lib/packageApi.ts +++ b/src/lib/packageApi.ts @@ -170,7 +170,7 @@ export type PackageVersionDetail = { }; type PluginFamily = "code-plugin" | "bundle-plugin"; -type PackageCatalogSort = "updated" | "recommended" | "installs"; +type PackageCatalogSort = "updated" | "recommended" | "downloads"; type PluginCatalogResult = { items: PackageListItem[]; diff --git a/src/lib/publicUser.ts b/src/lib/publicUser.ts index 5851e533..a16e3f0e 100644 --- a/src/lib/publicUser.ts +++ b/src/lib/publicUser.ts @@ -74,8 +74,8 @@ export type PublicPublisherCatalogDisplay = { sections: PublicPublisherCatalogSection[]; }; -export function readPublicInstallCount(value: { installs?: number; downloads?: number }) { - return value.installs ?? value.downloads ?? 0; +export function readPublicDownloadCount(value: { downloads?: number; installs?: number }) { + return value.downloads ?? value.installs ?? 0; } export type PublicSkill = Pick< diff --git a/src/lib/runtimeEnv.test.ts b/src/lib/runtimeEnv.test.ts index 037ba926..a621d203 100644 --- a/src/lib/runtimeEnv.test.ts +++ b/src/lib/runtimeEnv.test.ts @@ -4,6 +4,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { getRequiredRuntimeEnv, getRuntimeEnv, isDevRuntime } from "./runtimeEnv"; afterEach(() => { + vi.unstubAllGlobals(); vi.unstubAllEnvs(); }); @@ -13,6 +14,17 @@ describe("runtimeEnv", () => { expect(getRuntimeEnv("VITE_SITE_URL")).toBe("https://clawhub.ai"); }); + it("prefers import.meta.env in the browser", () => { + const originalClientValue = import.meta.env.VITE_SITE_URL; + vi.stubEnv("VITE_SITE_URL", "https://process.example"); + import.meta.env.VITE_SITE_URL = "https://client.example"; + vi.stubGlobal("window", {}); + + expect(getRuntimeEnv("VITE_SITE_URL")).toBe("https://client.example"); + + import.meta.env.VITE_SITE_URL = originalClientValue; + }); + it("throws for missing required env", () => { expect(() => getRequiredRuntimeEnv("VITE_MISSING_VALUE")).toThrow( "Missing required environment variable: VITE_MISSING_VALUE", diff --git a/src/lib/runtimeEnv.ts b/src/lib/runtimeEnv.ts index c8d5a807..dad1f7b4 100644 --- a/src/lib/runtimeEnv.ts +++ b/src/lib/runtimeEnv.ts @@ -15,6 +15,9 @@ function readClientMetaEnv(name: string) { } export function getRuntimeEnv(name: string) { + if (typeof window !== "undefined") { + return readClientMetaEnv(name) ?? readProcessEnv(name); + } return readProcessEnv(name) ?? readClientMetaEnv(name); } diff --git a/src/lib/skillPage.test.ts b/src/lib/skillPage.test.ts index 24d760c4..af63510b 100644 --- a/src/lib/skillPage.test.ts +++ b/src/lib/skillPage.test.ts @@ -64,6 +64,7 @@ describe("fetchSkillPageData", () => { }, }); expect(queryMock).toHaveBeenCalledWith("skills:getBySlug", { slug: "weather" }); + expect(queryMock).toHaveBeenCalledTimes(1); expect(actionMock).toHaveBeenCalledWith("skills:getReadme", { versionId: "skillVersions:1" }); }); diff --git a/src/lib/skillPage.ts b/src/lib/skillPage.ts index 78007629..09bdb0f6 100644 --- a/src/lib/skillPage.ts +++ b/src/lib/skillPage.ts @@ -81,6 +81,13 @@ function ownerMatchesLookup( return candidates.includes(requested); } +function readActionText(value: unknown) { + if (value && typeof value === "object" && "text" in value && typeof value.text === "string") { + return value.text; + } + return null; +} + async function querySkillBySlug(slug: string, ownerHandle?: string): Promise { if (!ownerHandle) { const result = (await convexHttp.query(api.skills.getBySlug, { slug })) as SkillBySlugResult; @@ -124,10 +131,10 @@ export async function fetchSkillPageData( if (result.latestVersion?._id) { try { - const response = (await convexHttp.action(api.skills.getReadme, { + const response = await convexHttp.action(api.skills.getReadme, { versionId: result.latestVersion._id, - })) as { text: string }; - readme = response.text; + }); + readme = readActionText(response); } catch (error) { readmeError = error instanceof Error ? error.message : "Failed to load SKILL.md"; } diff --git a/src/lib/useDeferredActivityTrend.ts b/src/lib/useDeferredActivityTrend.ts new file mode 100644 index 00000000..06f95fb6 --- /dev/null +++ b/src/lib/useDeferredActivityTrend.ts @@ -0,0 +1,130 @@ +import { useConvex } from "convex/react"; +import { useEffect, useState } from "react"; +import { + getPackageActivityTrendForName, + getSkillActivityTrendForSlug, + isActivityTrend, + type ActivityTrend, +} from "./activityTrend"; + +const ACTIVITY_TREND_FALLBACK_DELAY_MS = 750; + +type DeferredActivityTrendState = { + key: string | null; + loading: boolean; + trend: ActivityTrend | null; +}; + +type DeferredActivityTrendResult = { + loading: boolean; + trend: ActivityTrend | null; +}; + +function scheduleDeferredActivityTrend(load: () => void) { + const handle = setTimeout(load, ACTIVITY_TREND_FALLBACK_DELAY_MS); + return () => clearTimeout(handle); +} + +function resultForState(key: string | null, state: DeferredActivityTrendState) { + return { + loading: key !== null && (state.key !== key || state.loading), + trend: state.key === key ? state.trend : null, + }; +} + +export function useDeferredSkillActivityTrend( + params: { + slug: string; + ownerHandle?: string; + endDay: number; + } | null, +): DeferredActivityTrendResult { + const convex = useConvex(); + const slug = params?.slug ?? null; + const ownerHandle = params?.ownerHandle; + const endDay = params?.endDay ?? null; + const key = slug && endDay !== null ? `skill:${slug}:${ownerHandle ?? ""}:${endDay}` : null; + const [state, setState] = useState({ + key: null, + loading: false, + trend: null, + }); + + useEffect(() => { + if (!slug || endDay === null || key === null) { + setState({ key: null, loading: false, trend: null }); + return () => {}; + } + + let cancelled = false; + setState({ key, loading: true, trend: null }); + + const cancelSchedule = scheduleDeferredActivityTrend(() => { + const args = ownerHandle ? { slug, ownerHandle, endDay } : { slug, endDay }; + void convex.query(getSkillActivityTrendForSlug, args).then( + (value) => { + if (cancelled) return; + setState({ key, loading: false, trend: isActivityTrend(value) ? value : null }); + }, + () => { + if (cancelled) return; + setState({ key, loading: false, trend: null }); + }, + ); + }); + + return () => { + cancelled = true; + cancelSchedule(); + }; + }, [convex, endDay, key, ownerHandle, slug]); + + return resultForState(key, state); +} + +export function useDeferredPackageActivityTrend( + params: { + name: string; + endDay: number; + } | null, +): DeferredActivityTrendResult { + const convex = useConvex(); + const name = params?.name ?? null; + const endDay = params?.endDay ?? null; + const key = name && endDay !== null ? `package:${name}:${endDay}` : null; + const [state, setState] = useState({ + key: null, + loading: false, + trend: null, + }); + + useEffect(() => { + if (!name || endDay === null || key === null) { + setState({ key: null, loading: false, trend: null }); + return () => {}; + } + + let cancelled = false; + setState({ key, loading: true, trend: null }); + + const cancelSchedule = scheduleDeferredActivityTrend(() => { + void convex.query(getPackageActivityTrendForName, { name, endDay }).then( + (value) => { + if (cancelled) return; + setState({ key, loading: false, trend: isActivityTrend(value) ? value : null }); + }, + () => { + if (cancelled) return; + setState({ key, loading: false, trend: null }); + }, + ); + }); + + return () => { + cancelled = true; + cancelSchedule(); + }; + }, [convex, endDay, key, name]); + + return resultForState(key, state); +} diff --git a/src/routes/-dashboard.test.tsx b/src/routes/-dashboard.test.tsx index 6f72c712..aa2aa5b0 100644 --- a/src/routes/-dashboard.test.tsx +++ b/src/routes/-dashboard.test.tsx @@ -299,10 +299,10 @@ describe("Dashboard rows", () => { expect(screen.queryByText("Static")).toBeNull(); expect(screen.queryByText(/rescans/i)).toBeNull(); expect(screen.queryByText("Limit reached (3/3)")).toBeNull(); - expect(screen.getAllByText("Installs").length).toBe(2); - expect(screen.queryByText("Downloads")).toBeNull(); - expect(screen.getByText("56")).toBeTruthy(); - expect(screen.getByText("9")).toBeTruthy(); + expect(screen.getAllByText("Downloads").length).toBe(2); + expect(screen.queryByText("Installs")).toBeNull(); + expect(screen.getByText("1.2K")).toBeTruthy(); + expect(screen.getByText("42")).toBeTruthy(); expect(screen.getAllByText("Current version").length).toBe(2); expect(screen.getAllByText("Last updated").length).toBe(2); expect( diff --git a/src/routes/audits.tsx b/src/routes/audits.tsx index 88a90ab0..5d6b6577 100644 --- a/src/routes/audits.tsx +++ b/src/routes/audits.tsx @@ -29,6 +29,7 @@ type SkillAuditRow = { summary?: string; icon?: string; stats: { + downloads: number; installsAllTime?: number; stars: number; }; @@ -186,10 +187,8 @@ function itemHref(row: AuditRow) { return `/${encodeURIComponent(owner)}/${encodeURIComponent(row.skill.slug)}`; } -function installsForRow(row: AuditRow) { - return row.kind === "plugin" - ? row.package.stats.installs - : (row.skill.stats.installsAllTime ?? 0); +function downloadsForRow(row: AuditRow) { + return row.kind === "plugin" ? row.package.stats.downloads : row.skill.stats.downloads; } function AuditsPage() { @@ -249,8 +248,8 @@ function AuditsPage() {
{itemColumnLabel}
ClawScan
VirusTotal
-
- Installs +
+ Downloads
{isInitialLoading ? ( @@ -301,8 +300,8 @@ function AuditTableRow({ row }: { row: AuditRow }) { -
- {formatCompactStat(installsForRow(row))} +
+ {formatCompactStat(downloadsForRow(row))}
); diff --git a/src/routes/dashboard.tsx b/src/routes/dashboard.tsx index 88e00532..af2f1930 100644 --- a/src/routes/dashboard.tsx +++ b/src/routes/dashboard.tsx @@ -331,7 +331,7 @@ function SkillRow({ skill, ownerHandle }: { skill: DashboardSkill; ownerHandle: buildSkillHref(ownerHandle, skill.ownerPublisherId ?? skill.ownerUserId ?? null, skill.slug); const settingsHref = skill.settingsHref ?? `${detailHref}/settings`; const stats = [ - { label: "Installs", value: formatCompactNumber(skill.stats?.installsAllTime ?? 0) }, + { label: "Downloads", value: formatCompactNumber(skill.stats?.downloads ?? 0) }, { label: "Current version", value: formatVersion(skill.latestVersion?.version) }, { label: "Last updated", value: formatShortDate(skill.updatedAt) }, ]; @@ -357,7 +357,7 @@ function PackageRow({ pkg }: { pkg: DashboardPackage }) { const validationCount = pkg.inspectorWarningCount ?? 0; const titleId = `dashboard-package-title-${pkg._id}`; const stats = [ - { label: "Installs", value: formatCompactNumber(pkg.stats.installs ?? 0) }, + { label: "Downloads", value: formatCompactNumber(pkg.stats.downloads ?? 0) }, { label: "Current version", value: formatVersion(pkg.latestVersion) }, { label: "Last updated", value: formatShortDate(pkg.updatedAt) }, ]; diff --git a/src/routes/plugins/$name.tsx b/src/routes/plugins/$name.tsx index 4c080138..27061169 100644 --- a/src/routes/plugins/$name.tsx +++ b/src/routes/plugins/$name.tsx @@ -10,6 +10,7 @@ import { AlertTriangle, Download, Info, Upload } from "lucide-react"; import { useEffect, useState, type ReactNode } from "react"; import { toast } from "sonner"; import { api } from "../../../convex/_generated/api"; +import { ActivityMetricLabel } from "../../components/ActivityMetricLabel"; import { CatalogMetadataEditor } from "../../components/CatalogMetadataEditor"; import { CatalogTopicList } from "../../components/CatalogTopicList"; import { DetailHero, DetailPageShell } from "../../components/DetailPageShell"; @@ -21,6 +22,7 @@ import { EmptyState } from "../../components/EmptyState"; import { InstallCopyButton } from "../../components/InstallCopyButton"; import { Container } from "../../components/layout/Container"; import { MarkdownPreview } from "../../components/MarkdownPreview"; +import { MetricTrendCard, MetricTrendCardSkeleton } from "../../components/MetricTrendCard"; import { OfficialTag } from "../../components/OfficialBadge"; import { PLUGIN_VERSIONS_PAGE_SIZE, @@ -39,6 +41,7 @@ import { DialogHeader, DialogTitle, } from "../../components/ui/dialog"; +import { getActivityTrendEndDay } from "../../lib/activityTrend"; import { formatRetryDelay } from "../../lib/formatRetryDelay"; import { formatCompactStat } from "../../lib/numberFormat"; import { buildPluginMeta } from "../../lib/og"; @@ -63,6 +66,7 @@ import { } from "../../lib/pluginRoutes"; import { buildReadmeAssetBaseUrl } from "../../lib/readmeAssetBaseUrl"; import { useAuthStatus } from "../../lib/useAuthStatus"; +import { useDeferredPackageActivityTrend } from "../../lib/useDeferredActivityTrend"; type PluginDetailRateLimitState = { scope: "detail" | "metadata"; @@ -582,6 +586,10 @@ function PluginDetailPageContent({ name, loaderData }: PluginDetailPageProps) { api.packages.getPackageInspectorValidationSummaryPublic, detail.package ? { name: detail.package.name } : "skip", ) as PluginInspectorValidationSummary | undefined; + const activityTrendEndDay = getActivityTrendEndDay(); + const { trend: activityTrend, loading: activityTrendLoading } = useDeferredPackageActivityTrend( + detail.package ? { name: detail.package.name, endDay: activityTrendEndDay } : null, + ); const inspectorFindings = useQuery( api.packages.listPackageInspectorWarningsForManager, manageContext ? { name: manageContext.package.name, limit: 100 } : "skip", @@ -974,11 +982,31 @@ function PluginDetailPageContent({ name, loaderData }: PluginDetailPageProps) { ariaLabel="Plugin metadata" density="compact" blocks={[ - { - label: "Installs", - value: formatCompactStat(pkg.stats?.installs ?? 0), - large: true, - }, + activityTrendLoading + ? { + key: "download-trend-loading", + label: , + value: , + large: true, + } + : activityTrend + ? { + key: "download-trend", + label: , + value: ( + + ), + large: true, + } + : { + label: , + value: formatCompactStat(pkg.stats?.downloads ?? 0), + large: true, + }, { label: "Repository", value: sourceRepoLink }, { label: "Owner", value: ownerMetadataValue }, securitySummary diff --git a/src/routes/plugins/index.tsx b/src/routes/plugins/index.tsx index da4ac318..ebac1273 100644 --- a/src/routes/plugins/index.tsx +++ b/src/routes/plugins/index.tsx @@ -16,9 +16,9 @@ import { type PackageListItem, } from "../../lib/packageApi"; -type VisiblePluginSort = "recommended" | "updated" | "installs"; +type VisiblePluginSort = "recommended" | "updated" | "downloads"; type PluginSort = VisiblePluginSort | "relevance"; -type LegacyPluginSort = PluginSort | "newest" | "name"; +type LegacyPluginSort = PluginSort | "newest" | "name" | "installs"; const PLUGINS_PAGE_SIZE = 25; @@ -39,7 +39,7 @@ type LegacyPluginView = PluginView | "cards"; const PLUGIN_SORT_OPTIONS = [ { value: "recommended", label: "Recommended" }, - { value: "installs", label: "Most installed" }, + { value: "downloads", label: "Most downloaded" }, { value: "updated", label: "Recently updated" }, ]; @@ -96,11 +96,12 @@ function parsePluginSort(value: unknown): LegacyPluginSort | undefined { value === "recommended" || value === "relevance" || value === "updated" || + value === "downloads" || value === "installs" || value === "newest" || value === "name" ) { - return value; + return value === "installs" ? "downloads" : value; } return undefined; } @@ -115,8 +116,8 @@ function sortPluginSearchItems(items: PackageListItem[], sort: PluginSort) { a.family.localeCompare(b.family) || a.name.localeCompare(b.name); - if (sort === "installs") { - return (b.stats?.installs ?? 0) - (a.stats?.installs ?? 0) || tieBreak(); + if (sort === "downloads") { + return (b.stats?.downloads ?? 0) - (a.stats?.downloads ?? 0) || tieBreak(); } return tieBreak(); @@ -125,7 +126,7 @@ function sortPluginSearchItems(items: PackageListItem[], sort: PluginSort) { } function normalizeActivePluginSort(sort: LegacyPluginSort | undefined): PluginSort | undefined { - if (sort === "newest" || sort === "name") return undefined; + if (sort === "newest" || sort === "name" || sort === "installs") return undefined; return sort; } @@ -138,7 +139,7 @@ function hasPluginBrowseFilter( function getDefaultPluginBrowseSort( args: Pick, ): VisiblePluginSort { - return hasPluginBrowseFilter(args) ? "installs" : "recommended"; + return hasPluginBrowseFilter(args) ? "downloads" : "recommended"; } function hasPersistentPluginBrowseFilter( @@ -164,7 +165,7 @@ export async function loadPluginsPageData( featured: args.featured, isOfficial: args.official, ...(!args.q && - (args.sort === "installs" || + (args.sort === "downloads" || args.sort === "updated" || !args.sort || args.sort === "recommended") @@ -211,22 +212,17 @@ export async function loadPluginsPageData( export const Route = createFileRoute("/plugins/")({ pendingComponent: PluginsIndexPending, - validateSearch: (search): PluginSearchState => ({ - q: typeof search.q === "string" && search.q.trim() ? search.q.trim() : undefined, - category: + validateSearch: (search): PluginSearchState => { + const q = typeof search.q === "string" && search.q.trim() ? search.q.trim() : undefined; + const category = typeof search.category === "string" ? resolvePluginBrowseCategorySlug(search.category) - : undefined, - topic: typeof search.topic === "string" ? normalizeCatalogTopic(search.topic) : undefined, - cursor: - search.sort !== "downloads" && typeof search.cursor === "string" && search.cursor - ? search.cursor - : undefined, - featured: + : undefined; + const featured = search.featured === true || search.featured === "true" || search.featured === "1" ? true - : undefined, - official: + : undefined; + const official = search.official === true || search.official === "true" || search.official === "1" || @@ -234,17 +230,35 @@ export const Route = createFileRoute("/plugins/")({ search.verified === "true" || search.verified === "1" ? true - : undefined, - sort: parsePluginSort(search.sort), - view: normalizePluginView(search.view), - }), + : undefined; + const legacyInstallSort = search.sort === "installs"; + const noExplicitSort = search.sort === undefined; + const staleImplicitFilteredCursor = + noExplicitSort && !q && hasPersistentPluginBrowseFilter({ category, featured, official }); + return { + q, + category, + topic: typeof search.topic === "string" ? normalizeCatalogTopic(search.topic) : undefined, + cursor: + !legacyInstallSort && + !staleImplicitFilteredCursor && + typeof search.cursor === "string" && + search.cursor + ? search.cursor + : undefined, + featured, + official, + sort: parsePluginSort(search.sort), + view: normalizePluginView(search.view), + }; + }, beforeLoad: ({ search }) => { const hasQuery = Boolean(search.q?.trim()); const incompatibleSort = search.sort && search.sort !== "recommended" && search.sort !== "updated" && - search.sort !== "installs" && + search.sort !== "downloads" && !(hasQuery && search.sort === "relevance"); const staleFeatured = Boolean(hasQuery && search.featured); if (incompatibleSort || staleFeatured) { @@ -404,9 +418,11 @@ function PluginsIndex() { ); const activeSort: PluginSort = - search.sort === "relevance" || search.sort === "newest" || search.sort === "name" - ? "recommended" - : (search.sort ?? (hasQuery ? "recommended" : getDefaultPluginBrowseSort(search))); + search.sort === "installs" + ? "downloads" + : search.sort === "relevance" || search.sort === "newest" || search.sort === "name" + ? "recommended" + : (search.sort ?? (hasQuery ? "recommended" : getDefaultPluginBrowseSort(search))); const visibleItems = useMemo(() => { return hasQuery ? sortPluginSearchItems(items, activeSort) : items; }, [activeSort, hasQuery, items]); @@ -430,7 +446,7 @@ function PluginsIndex() { const isExplicitFilteredRecommendation = nextSort === "recommended" && !prev.q && hasPersistentPluginBrowseFilter(prev); const sort = - isExplicitFilteredRecommendation || nextSort === "installs" + isExplicitFilteredRecommendation || nextSort === "downloads" ? nextSort : nextSort === "updated" ? "updated" @@ -666,7 +682,14 @@ function PluginsIndex() { type="button" onClick={() => { void navigate({ - search: (prev: PluginSearchState) => ({ ...prev, cursor: nextCursor }), + search: (prev: PluginSearchState) => ({ + ...prev, + cursor: nextCursor, + sort: + !prev.q && !prev.sort && hasPersistentPluginBrowseFilter(prev) + ? getDefaultPluginBrowseSort(prev) + : prev.sort, + }), }); }} > diff --git a/src/routes/skills/-params.test.ts b/src/routes/skills/-params.test.ts index b6b54e36..17262069 100644 --- a/src/routes/skills/-params.test.ts +++ b/src/routes/skills/-params.test.ts @@ -2,11 +2,11 @@ import { describe, expect, it } from "vitest"; import { parseSort, sortKeys } from "./-params"; describe("skill sort params", () => { - it("normalizes legacy downloads sort links to installs", () => { - expect(parseSort("downloads")).toBe("installs"); + it("normalizes legacy installs sort links to downloads", () => { + expect(parseSort("installs")).toBe("downloads"); }); - it("does not expose downloads as a supported sort", () => { - expect(sortKeys).not.toContain("downloads"); + it("exposes downloads as a supported sort", () => { + expect(sortKeys).toContain("downloads"); }); }); diff --git a/src/routes/skills/-params.ts b/src/routes/skills/-params.ts index 404372e1..0507a3e4 100644 --- a/src/routes/skills/-params.ts +++ b/src/routes/skills/-params.ts @@ -3,7 +3,7 @@ export const sortKeys = [ "recommended", "default", "newest", - "installs", + "downloads", "stars", "name", "updated", @@ -16,7 +16,7 @@ export type SortDir = "asc" | "desc"; export function parseSort(value: unknown): SortKey { if (typeof value !== "string") return "recommended"; if (value === "default") return "recommended"; - if (value === "downloads") return "installs"; + if (value === "installs") return "downloads"; if ((sortKeys as readonly string[]).includes(value)) return value as SortKey; return "recommended"; } diff --git a/src/routes/skills/-useSkillsBrowseModel.ts b/src/routes/skills/-useSkillsBrowseModel.ts index 45aa6e41..a3a67b46 100644 --- a/src/routes/skills/-useSkillsBrowseModel.ts +++ b/src/routes/skills/-useSkillsBrowseModel.ts @@ -278,11 +278,8 @@ export function useSkillsBrowseModel({ switch (sort) { case "relevance": return ((a.searchScore ?? 0) - (b.searchScore ?? 0)) * multiplier; - case "installs": - return ( - ((a.skill.stats.installsAllTime ?? 0) - (b.skill.stats.installsAllTime ?? 0)) * - multiplier || tieBreak() - ); + case "downloads": + return (a.skill.stats.downloads - b.skill.stats.downloads) * multiplier || tieBreak(); case "stars": return (a.skill.stats.stars - b.skill.stats.stars) * multiplier || tieBreak(); case "updated": diff --git a/src/routes/skills/index.tsx b/src/routes/skills/index.tsx index 277561d4..a1d624bc 100644 --- a/src/routes/skills/index.tsx +++ b/src/routes/skills/index.tsx @@ -18,7 +18,7 @@ import { const BROWSE_SORT_OPTIONS = [ { value: "recommended", label: "Recommended" }, { value: "stars", label: "Most starred" }, - { value: "installs", label: "Most installed" }, + { value: "downloads", label: "Most downloaded" }, { value: "updated", label: "Recently updated" }, { value: "newest", label: "Newest" }, { value: "name", label: "Name" }, diff --git a/src/routes/user/$handle.tsx b/src/routes/user/$handle.tsx index 749db944..e56f21e8 100644 --- a/src/routes/user/$handle.tsx +++ b/src/routes/user/$handle.tsx @@ -1,15 +1,7 @@ import { createFileRoute, Link, notFound } from "@tanstack/react-router"; import { normalizeCatalogTopic } from "clawhub-schema"; import { usePaginatedQuery, useQuery } from "convex/react"; -import { - Building2, - Package, - PackageCheck, - Star, - Users, - Wrench, - type LucideIcon, -} from "lucide-react"; +import { Building2, Download, Package, Star, Users, Wrench, type LucideIcon } from "lucide-react"; import { type ReactNode, useMemo, useState } from "react"; import { api } from "../../../convex/_generated/api"; import { EmptyState } from "../../components/EmptyState"; @@ -28,7 +20,7 @@ import type { PublicPublisherCatalogItem, PublicPublisherListItem, } from "../../lib/publicUser"; -import { readPublicInstallCount } from "../../lib/publicUser"; +import { readPublicDownloadCount } from "../../lib/publicUser"; export const Route = createFileRoute("/user/$handle")({ loader: async ({ params }) => { @@ -241,9 +233,9 @@ function PublisherProfile() {
-
-