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.
This commit is contained in:
Jesse Merhi
2026-06-22 11:59:30 +10:00
committed by GitHub
parent a86c48ce3b
commit 379c1871f4
79 changed files with 4690 additions and 495 deletions
+2
View File
@@ -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;
+19
View File
@@ -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",
},
);
});
});
+13 -1
View File
@@ -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 },
+307
View File
@@ -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<Record<string, unknown>>
)._handler;
const seedPublicCorpusBatchHandler = (
seedPublicCorpusBatchMutation as unknown as WrappedHandler<Record<string, unknown>>
)._handler;
const backfillExistingPublicCorpusBatchRowsHandler = (
backfillExistingPublicCorpusBatchRows as unknown as WrappedHandler<Record<string, unknown>>
)._handler;
function chainEq(constraints: Record<string, unknown>) {
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<string, unknown>) =>
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({
+289 -6
View File
@@ -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<string, unknown>;
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<typeof internalAction> = internalActi
handler: seedLocalFixturesHandler,
});
export const backfillExistingPublicCorpusBatchRows = internalMutation({
args: {
rows: v.array(publicCorpusSeedRowValidator),
},
handler: async (ctx, args): Promise<PublicCorpusExistingRowsResult> => {
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<typeof internalAction> = internalAction({
args: {
reset: v.optional(v.boolean()),
@@ -817,8 +887,21 @@ export const seedPublicCorpusBatch: ReturnType<typeof internalAction> = 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<typeof internalAction> = 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<MutationCtx, "db">,
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<MutationCtx, "db">,
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,
@@ -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", () => ({
+400 -38
View File
@@ -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<string, unknown>) => {
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<string, unknown>) => {
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<string | null> = [];
const runQuery = vi.fn((_, args: Record<string, unknown>) => {
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<string, unknown>) => {
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<string, unknown>) => {
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<string, unknown>) => {
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<string, unknown>) => {
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<string | null> = [];
const runQuery = vi.fn((_, args: Record<string, unknown>) => {
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<string, unknown>) => {
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<string, unknown>) => {
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<string, unknown>) => {
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<string, unknown>) => {
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<string | null> = [];
const runQuery = vi.fn((_, args: Record<string, unknown>) => {
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<string, unknown>) => {
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<string, unknown>) => {
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 () => {
+135 -38
View File
@@ -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<T> = {
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<UnifiedCatalogCursorState>;
const normalize = (
input: Partial<CatalogSourceCursorState> | 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<CatalogSourceCursorState> | 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<PluginCatalogCursorState>;
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
? {
+17 -17
View File
@@ -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<SkillListSort, "trending">): 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) {
+40
View File
@@ -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);
});
});
+62
View File
@@ -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<number, number>();
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),
};
}
+15
View File
@@ -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",
});
});
});
+3 -2
View File
@@ -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",
+1
View File
@@ -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", () => ({
+31 -3
View File
@@ -63,8 +63,8 @@ import {
} from "./packages";
vi.mock("@convex-dev/auth/server", () => ({
authTables: {},
getAuthUserId: vi.fn(),
authTables: {},
}));
type WrappedHandler<TArgs, TResult> = {
@@ -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 () => {
+737 -13
View File
@@ -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<TArgs, TResult> = {
_handler: (ctx: unknown, args: TArgs) => Promise<TResult>;
};
@@ -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();
}
});
});
+417 -2
View File
@@ -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<Id<"packages">, { downloads: number; installs: number }>();
const dailyStatsByPackageDay = new Map<string, PackageDailyStatsDelta>();
const dailyStatsByPackage = new Map<Id<"packages">, 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<ProcessedPackageStatEventPruneBatchResult> => {
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<typeof internalAction> =
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<ProcessedPackageStatEventPruneResult> => {
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", {
+255 -37
View File
@@ -29,6 +29,7 @@ import {
vi.mock("@convex-dev/auth/server", () => ({
getAuthUserId: vi.fn(),
authTables: {},
}));
type WrappedHandler<TArgs, TResult = unknown> = {
@@ -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<string, unknown> = {};
const explicitFields = new Set<string>();
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,
},
]);
});
+70 -37
View File
@@ -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<ReturnType<typeof toPublicPublisher>> & {
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<PublisherListSummary | null> {
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<Id<"publishers">, 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<QueryCtx, "db">,
kindFilter?: PublicPublisherKindFilter,
): Promise<Doc<"publishers">[]> {
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<QueryCtx, "db">,
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;
+88
View File
@@ -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,
+1
View File
@@ -24,6 +24,7 @@ import {
vi.mock("@convex-dev/auth/server", () => ({
getAuthUserId: vi.fn(),
authTables: {},
}));
type WrappedHandler<TArgs, TResult = unknown> = {
+43 -15
View File
@@ -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([
+85
View File
@@ -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<string, unknown> | null;
latestVersion?: Record<string, unknown> | null;
githubScan?: Record<string, unknown> | null;
skillDailyStats?: Array<Record<string, unknown>>;
skillsById?: Record<string, Record<string, unknown>>;
ownersById?: Record<string, Record<string, unknown>>;
}) {
@@ -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({
+54 -4
View File
@@ -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),
);
});
});
+1 -1
View File
@@ -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<TArgs, TResult = unknown> = {
+67 -11
View File
@@ -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<QueryCtx, "db">,
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<Id<"users">, Promise<Doc<"users"> | 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<SkillCatalogCursorState>;
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 };
+1
View File
@@ -8,6 +8,7 @@ vi.mock("./skillStatEvents", () => ({
vi.mock("@convex-dev/auth/server", () => ({
getAuthUserId: vi.fn(),
authTables: {},
}));
vi.mock("./functions", () => ({
+53
View File
@@ -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", () => {
+2 -1
View File
@@ -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,
};
},
+3 -3
View File
@@ -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`
+1 -1
View File
@@ -54,7 +54,7 @@ pnpm add -g clawhub
| Bundle plugins | Packaged plugin bundles for OpenClaw distribution | `clawhub package publish <source>` |
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
+1 -1
View File
@@ -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 <n>` (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: `<slug> v<version> <age> <summary>` (summary truncated to 50 chars).
+5 -3
View File
@@ -142,7 +142,7 @@ Query params:
- `limit` (optional): integer (1200)
- `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`.
+1 -1
View File
@@ -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.
+1 -5
View File
@@ -351,11 +351,7 @@ registerCommand(program, ["explore"])
(value) => Number.parseInt(value, 10),
25,
)
.option(
"--sort <order>",
"Sort by newest, rating, installs, installsAllTime, or trending",
"newest",
)
.option("--sort <order>", "Sort by newest, rating, downloads, or trending", "newest")
.option("--json", "Output JSON")
.action(async (options) => {
const opts = await resolveGlobalOpts();
@@ -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", () => {
+7 -8
View File
@@ -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.`,
);
}
+57
View File
@@ -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",
+13
View File
@@ -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.
+24 -8
View File
@@ -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);
});
});
+134 -6
View File
@@ -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(<Component />);
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(<Component />);
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(<Component />);
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(<Component />);
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();
});
+124 -57
View File
@@ -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<string, unknown>,
) => Record<string, unknown>;
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<string, unknown>,
) => Record<string, unknown>;
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<string, unknown>,
) => Record<string, unknown>;
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(<Component />);
fireEvent.click(screen.getByRole("button", { name: "Next page" }));
expect(navigateMock).toHaveBeenCalled();
const lastCall = navigateMock.mock.calls.at(-1)?.[0] as {
search: (prev: Record<string, unknown>) => Record<string, unknown>;
};
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(<Component />);
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(<Component />);
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(<Component />);
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<string, unknown>) => Record<string, unknown>;
@@ -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(<Component />);
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(<Component />);
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(<Component />);
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();
});
+212 -1
View File
@@ -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(
<SkillDetailPage slug="weather" initialData={initialData} />,
);
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(<SkillDetailPage slug="weather" initialData={initialData} />);
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(<SkillDetailPage slug="weather" canonicalOwner="users:1" initialData={initialData} />);
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;
+13 -13
View File
@@ -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(<SkillsIndex />);
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(<SkillsIndex />);
@@ -445,9 +445,9 @@ describe("SkillsIndex", () => {
search: (prev: Record<string, unknown>) => Record<string, unknown>;
};
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(<SkillsIndex />);
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(<SkillsIndex />);
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(<SkillsIndex />);
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,
});
+6 -6
View File
@@ -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(<Component />);
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;
+29
View File
@@ -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 (
<span className="activity-metric-label">
<span>{label}</span>
<TooltipProvider delayDuration={400}>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="activity-metric-info"
aria-label="About activity counts"
>
<Info size={13} aria-hidden="true" />
</button>
</TooltipTrigger>
<TooltipContent side="top" align="start" className="activity-metric-tooltip">
{DOWNLOAD_COUNT_HELP}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</span>
);
}
+9 -9
View File
@@ -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
<div className="home-v2-listing-row-stats" aria-label="Popularity">
<span>
<Download size={13} aria-hidden="true" />
{formatCompactStat(entry.skill.stats?.installsAllTime ?? 0)}
{formatCompactStat(entry.skill.stats?.downloads ?? 0)}
</span>
</div>
) : null}
@@ -404,7 +404,7 @@ function HomeListingPluginRow({ plugin }: { plugin: PackageListItem }) {
<div className="home-v2-listing-row-stats" aria-label="Popularity">
<span>
<Download size={13} aria-hidden="true" />
{formatCompactStat(plugin.stats?.installs ?? 0)}
{formatCompactStat(plugin.stats?.downloads ?? 0)}
</span>
</div>
</Link>
@@ -436,7 +436,7 @@ function HomeListingSkillCard({ entry, showStats }: { entry: SkillPageEntry; sho
<div className="home-v2-listing-card-stats" aria-label="Popularity">
<span>
<Download size={13} aria-hidden="true" />
{formatCompactStat(entry.skill.stats?.installsAllTime ?? 0)}
{formatCompactStat(entry.skill.stats?.downloads ?? 0)}
</span>
</div>
) : null}
@@ -469,7 +469,7 @@ function HomeListingPluginCard({ plugin }: { plugin: PackageListItem }) {
<div className="home-v2-listing-card-stats" aria-label="Popularity">
<span>
<Download size={13} aria-hidden="true" />
{formatCompactStat(plugin.stats?.installs ?? 0)}
{formatCompactStat(plugin.stats?.downloads ?? 0)}
</span>
</div>
</Link>
@@ -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,
}),
+155
View File
@@ -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<number | null>(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<SVGSVGElement> | MouseEvent<SVGSVGElement>) {
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 (
<div className="metric-trend-card">
<div className="metric-trend-value-row">
<strong>{formatCompactStat(trend.total)}</strong>
<span className="metric-trend-point-label" id={descriptionId}>
{activeLabel}
</span>
</div>
<svg
className="metric-trend-chart"
viewBox="0 0 100 34"
role="img"
aria-label={ariaLabel}
aria-describedby={descriptionId}
preserveAspectRatio="none"
tabIndex={0}
onFocus={showLatestPoint}
onBlur={() => setActiveIndex(null)}
onMouseEnter={showNearestPoint}
onMouseMove={showNearestPoint}
onPointerEnter={showNearestPoint}
onPointerMove={showNearestPoint}
onPointerLeave={() => setActiveIndex(null)}
>
<path className="metric-trend-area" d={chart.area} aria-hidden="true" />
<polyline
className="metric-trend-line"
points={chart.line}
vectorEffect="non-scaling-stroke"
aria-hidden="true"
/>
{activeCoord ? (
<line
className="metric-trend-marker-line"
x1={activeCoord.x}
x2={activeCoord.x}
y1="2"
y2="32"
vectorEffect="non-scaling-stroke"
aria-hidden="true"
/>
) : null}
</svg>
</div>
);
}
export function MetricTrendCardSkeleton() {
return (
<div className="metric-trend-card metric-trend-card-skeleton" aria-hidden="true">
<div className="metric-trend-value-row">
<span className="metric-trend-skeleton-total" />
<span className="metric-trend-skeleton-label" />
</div>
<div className="metric-trend-skeleton-chart" />
</div>
);
}
+4 -4
View File
@@ -1,6 +1,6 @@
import { Link } from "@tanstack/react-router";
import { PLUGIN_CATEGORY_DEFINITIONS } from "clawhub-schema";
import { PackageCheck } from "lucide-react";
import { Download } from "lucide-react";
import { formatCompactStat } from "../lib/numberFormat";
import type { PackageListItem } from "../lib/packageApi";
import { CatalogTopicList } from "./CatalogTopicList";
@@ -28,7 +28,7 @@ function getPluginTaxonomyDisplay(item: PackageListItem) {
}
export function PluginListItem({ item, variant = "list" }: PluginListItemProps) {
const installs = formatCompactStat(item.stats?.installs ?? 0);
const downloads = formatCompactStat(item.stats?.downloads ?? 0);
const taxonomy = getPluginTaxonomyDisplay(item);
if (variant === "card") {
@@ -59,7 +59,7 @@ export function PluginListItem({ item, variant = "list" }: PluginListItemProps)
<span className="skill-list-item-meta-item">v{item.latestVersion}</span>
) : null}
<span className="skill-list-item-meta-item">
<PackageCheck size={14} aria-hidden="true" /> {installs}
<Download size={14} aria-hidden="true" /> {downloads}
</span>
<span className="skill-list-item-meta-item">
{item.ownerHandle ? `@${item.ownerHandle}` : "community"}
@@ -99,7 +99,7 @@ export function PluginListItem({ item, variant = "list" }: PluginListItemProps)
<span className="skill-list-item-meta-item">v{item.latestVersion}</span>
) : null}
<span className="skill-list-item-meta-item">
<PackageCheck size={14} aria-hidden="true" /> {installs}
<Download size={14} aria-hidden="true" /> {downloads}
</span>
<span className="skill-list-item-meta-item">
{item.ownerHandle ? `@${item.ownerHandle}` : "community"}
+8 -8
View File
@@ -50,16 +50,16 @@ const basePlugin = {
};
describe("PublishedItemCard", () => {
it("renders installs instead of downloads", () => {
it("renders downloads", () => {
render(<PublishedItemCard item={{ ...baseSkill, icon: null }} view="list" />);
expect(screen.getByText("8")).toBeTruthy();
expect(screen.getByText("installs")).toBeTruthy();
expect(screen.queryByText("downloads")).toBeNull();
expect(screen.queryByText("42")).toBeNull();
expect(screen.getByText("42")).toBeTruthy();
expect(screen.getByText("downloads")).toBeTruthy();
expect(screen.queryByText("installs")).toBeNull();
expect(screen.queryByText("8")).toBeNull();
});
it("renders the legacy backend metric as installs during rollout", () => {
it("renders the legacy backend metric as downloads", () => {
render(
<PublishedItemCard
item={{ ...baseSkill, downloads: 42, installs: undefined, icon: null } as never}
@@ -68,8 +68,8 @@ describe("PublishedItemCard", () => {
);
expect(screen.getByText("42")).toBeTruthy();
expect(screen.getByText("installs")).toBeTruthy();
expect(screen.queryByText("downloads")).toBeNull();
expect(screen.getByText("downloads")).toBeTruthy();
expect(screen.queryByText("installs")).toBeNull();
});
describe("grid view", () => {
+8 -8
View File
@@ -20,16 +20,16 @@ describe("PublisherListItem", () => {
expect(container.querySelector(".official-badge")).toBeTruthy();
});
it("renders installs instead of downloads as the adoption metric", () => {
it("renders downloads as the adoption metric", () => {
render(<PublisherListItem publisher={makePublisher()} />);
expect(screen.getByText("34")).toBeTruthy();
expect(screen.getByText("installs")).toBeTruthy();
expect(screen.queryByText("downloads")).toBeNull();
expect(screen.queryByText("12")).toBeNull();
expect(screen.getByText("12")).toBeTruthy();
expect(screen.getByText("downloads")).toBeTruthy();
expect(screen.queryByText("installs")).toBeNull();
expect(screen.queryByText("34")).toBeNull();
});
it("renders legacy preview metrics as installs during rollout", () => {
it("renders legacy preview metrics as downloads", () => {
const publisher = makePublisher();
publisher.publishedItems = [
{ kind: "skill", displayName: "Legacy Skill", downloads: 12 } as never,
@@ -37,8 +37,8 @@ describe("PublisherListItem", () => {
render(<PublisherListItem publisher={publisher} variant="highlight" />);
expect(screen.getByText("12")).toBeTruthy();
expect(screen.queryByText("downloads")).toBeNull();
expect(screen.getAllByText("12")).toHaveLength(2);
expect(screen.getByText("downloads")).toBeTruthy();
});
});
+8 -8
View File
@@ -1,10 +1,10 @@
import { Link } from "@tanstack/react-router";
import { PackageCheck } from "lucide-react";
import { Download } from "lucide-react";
import { formatCompactStat } from "../lib/numberFormat";
import {
type PublicPublisherListItem,
type PublicPublisherPublishedItem,
readPublicInstallCount,
readPublicDownloadCount,
} from "../lib/publicUser";
import { MarketplaceIcon } from "./MarketplaceIcon";
import { OfficialBadge } from "./OfficialBadge";
@@ -67,9 +67,9 @@ export function PublisherListItem({ publisher, variant = "list" }: PublisherList
<span key={`${item.kind}:${item.displayName}`}>
<MarketplaceIcon kind={item.kind} label={item.displayName} size="xs" />
<span className="publisher-card-featured-label">{item.displayName}</span>
<span className="publisher-card-featured-installs">
<PackageCheck size={12} aria-hidden="true" />
<span>{formatCompactStat(readPublicInstallCount(item))}</span>
<span className="publisher-card-featured-downloads">
<Download size={12} aria-hidden="true" />
<span>{formatCompactStat(readPublicDownloadCount(item))}</span>
</span>
</span>
))}
@@ -85,9 +85,9 @@ export function PublisherListItem({ publisher, variant = "list" }: PublisherList
published
</span>
<span className="publisher-card-stat is-primary">
<PackageCheck size={14} aria-hidden="true" />
<strong>{formatCompactStat(publisher.stats.installs)}</strong>
installs
<Download size={14} aria-hidden="true" />
<strong>{formatCompactStat(publisher.stats.downloads)}</strong>
downloads
</span>
</div>
</Link>
+16
View File
@@ -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}
+77 -3
View File
@@ -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();
+38 -1
View File
@@ -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 ? (
<div className="skill-sidebar-actions">
@@ -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: <ActivityMetricLabel label="30-day Downloads" />,
value: <MetricTrendCardSkeleton />,
large: true,
}
: activityTrend
? {
key: "download-trend",
label: <ActivityMetricLabel label="30-day Downloads" />,
value: (
<MetricTrendCard
trend={activityTrend.downloads}
ariaLabel="Daily downloads over the last 30 days"
unitLabel="download"
/>
),
large: true,
}
: {
label: <ActivityMetricLabel label="Downloads" />,
value: formattedStats.downloads,
large: true,
},
{ label: "Repository", value: githubRepositoryLink },
{
label: "Owner",
+2 -3
View File
@@ -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)
<Star size={14} aria-hidden="true" /> {formatCompactStat(skill.stats.stars)}
</span>
<span className="skill-list-item-meta-item">
<PackageCheck size={14} aria-hidden="true" />{" "}
{formatCompactStat(skill.stats.installsAllTime ?? 0)}
<Download size={14} aria-hidden="true" /> {formatCompactStat(skill.stats.downloads)}
</span>
</div>
</div>
+3 -3
View File
@@ -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 }) {
</span>
<span className="skill-stats-dot">·</span>
<span className="skill-stats-item">
<PackageCheck size={14} aria-hidden="true" />
{formatted.installsAllTime}
<Download size={14} aria-hidden="true" />
{formatted.downloads}
</span>
</span>
);
+3 -3
View File
@@ -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);
});
+6 -6
View File
@@ -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({
</span>
<span
className="flex items-center gap-1 text-fs-xs text-ink-soft"
title="Total installs"
title="Total downloads"
>
<PackageCheck size={12} />
{formatCompactStat(getHoverTotalInstalls(stats))}
<Download size={12} />
{formatCompactStat(getHoverTotalDownloads(stats))}
</span>
</>
)}
+49
View File
@@ -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<string, unknown> {
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);
}
+4 -4
View File
@@ -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 () => {
+1 -1
View File
@@ -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[];
+2 -2
View File
@@ -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<
+12
View File
@@ -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",
+3
View File
@@ -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);
}
+1
View File
@@ -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" });
});
+10 -3
View File
@@ -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<SkillLookupResult> {
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";
}
+130
View File
@@ -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<DeferredActivityTrendState>({
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<DeferredActivityTrendState>({
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);
}
+4 -4
View File
@@ -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(
+7 -8
View File
@@ -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() {
<div role="columnheader">{itemColumnLabel}</div>
<div role="columnheader">ClawScan</div>
<div role="columnheader">VirusTotal</div>
<div role="columnheader" className="audits-installs-header">
Installs
<div role="columnheader" className="audits-downloads-header">
Downloads
</div>
</div>
{isInitialLoading ? (
@@ -301,8 +300,8 @@ function AuditTableRow({ row }: { row: AuditRow }) {
</div>
<AuditSignalCell status={clawScanStatus} />
<AuditSignalCell status={vtStatus} />
<div role="cell" className="audits-installs-cell">
{formatCompactStat(installsForRow(row))}
<div role="cell" className="audits-downloads-cell">
{formatCompactStat(downloadsForRow(row))}
</div>
</div>
);
+2 -2
View File
@@ -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) },
];
+33 -5
View File
@@ -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: <ActivityMetricLabel label="30-day Downloads" />,
value: <MetricTrendCardSkeleton />,
large: true,
}
: activityTrend
? {
key: "download-trend",
label: <ActivityMetricLabel label="30-day Downloads" />,
value: (
<MetricTrendCard
trend={activityTrend.downloads}
ariaLabel="Daily downloads over the last 30 days"
unitLabel="download"
/>
),
large: true,
}
: {
label: <ActivityMetricLabel label="Downloads" />,
value: formatCompactStat(pkg.stats?.downloads ?? 0),
large: true,
},
{ label: "Repository", value: sourceRepoLink },
{ label: "Owner", value: ownerMetadataValue },
securitySummary
+54 -31
View File
@@ -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<PluginsPageDataRequest, "category" | "featured" | "official">,
): 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,
}),
});
}}
>
+4 -4
View File
@@ -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");
});
});
+2 -2
View File
@@ -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";
}
+2 -5
View File
@@ -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":
+1 -1
View File
@@ -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" },
+9 -17
View File
@@ -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() {
</div>
<div className="publisher-profile-hero-stats" aria-label="Publisher stats">
<PublisherStat
icon={PackageCheck}
value={formatCompactStat(publisher.stats.installs)}
label="installs"
icon={Download}
value={formatCompactStat(publisher.stats.downloads)}
label="downloads"
/>
<PublisherStat
icon={Star}
@@ -586,8 +578,8 @@ export function PublishedItemCard({
<div className="skill-card-footer">
<div className="skill-card-footer-inline publisher-published-card-stats">
<span className="skill-list-item-meta-item">
<PackageCheck size={14} aria-hidden="true" />
<strong>{formatCompactStat(readPublicInstallCount(item))}</strong> installs
<Download size={14} aria-hidden="true" />
<strong>{formatCompactStat(readPublicDownloadCount(item))}</strong> downloads
</span>
<span className="skill-list-item-meta-item">
<Star size={14} aria-hidden="true" />
@@ -617,8 +609,8 @@ export function PublishedItemCard({
</div>
<div className="skill-list-item-meta publisher-published-row-stats">
<span className="skill-list-item-meta-item">
<PackageCheck size={14} aria-hidden="true" />
<strong>{formatCompactStat(readPublicInstallCount(item))}</strong> installs
<Download size={14} aria-hidden="true" />
<strong>{formatCompactStat(readPublicDownloadCount(item))}</strong> downloads
</span>
<span className="skill-list-item-meta-item">
<Star size={14} aria-hidden="true" />
+163 -10
View File
@@ -5081,6 +5081,159 @@ code {
gap: 8px;
}
.activity-metric-label {
display: inline-flex;
min-width: 0;
align-items: center;
gap: 6px;
}
.activity-metric-info {
display: inline-flex;
position: relative;
width: 44px;
height: 44px;
align-items: center;
justify-content: center;
margin: -12px;
padding: 0;
border: 0;
border-radius: 999px;
background: transparent;
color: var(--ink-soft);
cursor: help;
}
.activity-metric-info::before {
position: absolute;
inset: 12px;
border: 1px solid var(--line);
border-radius: 999px;
background: var(--surface);
content: "";
}
.activity-metric-info svg {
position: relative;
z-index: 1;
}
.activity-metric-info:hover,
.activity-metric-info:focus-visible {
color: var(--accent);
}
.activity-metric-info:hover::before,
.activity-metric-info:focus-visible::before {
border-color: var(--accent);
}
.activity-metric-info:focus-visible {
outline: 2px solid var(--input-focus-border);
outline-offset: 2px;
}
.activity-metric-tooltip {
max-width: 260px;
line-height: 1.4;
}
.metric-trend-card {
display: grid;
gap: 8px;
width: 100%;
min-width: 0;
}
.metric-trend-card-skeleton {
height: 58px;
}
.metric-trend-value-row {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 10px;
}
.metric-trend-value-row strong {
color: var(--ink);
font-size: inherit;
font-weight: inherit;
line-height: 1;
}
.metric-trend-point-label {
color: var(--ink-muted);
font-size: 0.8125rem;
font-weight: 500;
line-height: 1.2;
text-align: right;
white-space: nowrap;
}
.metric-trend-chart {
display: block;
width: 100%;
height: 34px;
color: var(--accent);
outline: none;
touch-action: pan-y;
}
.metric-trend-chart:focus-visible {
outline: 2px solid var(--input-focus-border);
outline-offset: 3px;
}
.metric-trend-area {
fill: color-mix(in srgb, var(--accent) 16%, transparent);
}
.metric-trend-line {
fill: none;
stroke: currentColor;
stroke-linecap: round;
stroke-linejoin: round;
stroke-width: 2;
}
.metric-trend-marker-line {
stroke: color-mix(in srgb, var(--accent) 70%, transparent);
stroke-width: 1.5;
}
.metric-trend-skeleton-total,
.metric-trend-skeleton-label,
.metric-trend-skeleton-chart {
display: block;
border-radius: 999px;
background: linear-gradient(
90deg,
color-mix(in srgb, var(--ink) 8%, transparent),
color-mix(in srgb, var(--ink) 14%, transparent),
color-mix(in srgb, var(--ink) 8%, transparent)
);
background-size: 200% 100%;
animation: skeleton-shimmer 1.15s ease-in-out infinite;
}
.metric-trend-skeleton-total {
width: 54px;
height: 18px;
}
.metric-trend-skeleton-label {
width: 112px;
height: 14px;
}
.metric-trend-skeleton-chart {
width: 100%;
height: 34px;
border-radius: 6px;
}
.security-audit-version-stack {
display: grid;
justify-items: start;
@@ -11043,7 +11196,7 @@ a.agentic-risk-finding-title:focus-visible {
white-space: nowrap;
}
.publisher-card-featured-installs {
.publisher-card-featured-downloads {
justify-self: end;
display: inline-flex;
align-items: center;
@@ -11055,12 +11208,12 @@ a.agentic-risk-finding-title:focus-visible {
white-space: nowrap;
}
.publisher-card-featured-installs svg {
.publisher-card-featured-downloads svg {
flex: 0 0 auto;
color: var(--ink-faint);
}
.publisher-card-featured-installs span {
.publisher-card-featured-downloads span {
display: block;
font-family: var(--font-mono);
text-align: right;
@@ -11709,8 +11862,8 @@ a.agentic-risk-finding-title:focus-visible {
text-align: right;
}
.audits-installs-header,
.audits-installs-cell {
.audits-downloads-header,
.audits-downloads-cell {
justify-content: flex-end;
text-align: right;
}
@@ -11761,7 +11914,7 @@ a.agentic-risk-finding-title:focus-visible {
min-width: 0;
}
.audits-installs-cell {
.audits-downloads-cell {
display: flex;
align-items: center;
gap: 8px;
@@ -11852,7 +12005,7 @@ a.agentic-risk-finding-title:focus-visible {
}
.audits-signal-cell::before,
.audits-installs-cell::before {
.audits-downloads-cell::before {
min-width: 112px;
color: var(--ink-soft);
font-size: var(--fs-xs);
@@ -11869,13 +12022,13 @@ a.agentic-risk-finding-title:focus-visible {
content: "VirusTotal";
}
.audits-installs-cell {
.audits-downloads-cell {
justify-content: flex-start;
text-align: left;
}
.audits-installs-cell::before {
content: "Installs";
.audits-downloads-cell::before {
content: "Downloads";
}
}