mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 00:47:57 +00:00
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:
Vendored
+2
@@ -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;
|
||||
|
||||
@@ -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
@@ -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 },
|
||||
|
||||
@@ -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
@@ -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", () => ({
|
||||
|
||||
@@ -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
@@ -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
|
||||
? {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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", () => ({
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
|
||||
vi.mock("@convex-dev/auth/server", () => ({
|
||||
getAuthUserId: vi.fn(),
|
||||
authTables: {},
|
||||
}));
|
||||
|
||||
type WrappedHandler<TArgs, TResult = unknown> = {
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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
@@ -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 };
|
||||
|
||||
@@ -8,6 +8,7 @@ vi.mock("./skillStatEvents", () => ({
|
||||
|
||||
vi.mock("@convex-dev/auth/server", () => ({
|
||||
getAuthUserId: vi.fn(),
|
||||
authTables: {},
|
||||
}));
|
||||
|
||||
vi.mock("./functions", () => ({
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -142,7 +142,7 @@ Query params:
|
||||
|
||||
- `limit` (optional): integer (1–200)
|
||||
- `cursor` (optional): pagination cursor for any non-`trending` sort
|
||||
- `sort` (optional): `updated` (default), `recommended` (alias: `default`), `createdAt` (alias: `newest`), `stars` (alias: `rating`), `installsCurrent` (alias: `installs`), `installsAllTime`, `trending`
|
||||
- `sort` (optional): `updated` (default), `recommended` (alias: `default`), `createdAt` (alias: `newest`), `downloads`, `stars` (alias: `rating`), legacy install aliases `installsCurrent`/`installs`/`installsAllTime` map to `downloads`, `trending`
|
||||
- `nonSuspiciousOnly` (optional): `true` to hide suspicious (`flagged.suspicious`) skills
|
||||
- `nonSuspicious` (optional): legacy alias for `nonSuspiciousOnly`
|
||||
|
||||
@@ -165,6 +165,7 @@ Response:
|
||||
"slug": "gifgrep",
|
||||
"displayName": "GifGrep",
|
||||
"summary": "…",
|
||||
"topics": ["Productivity"],
|
||||
"tags": { "latest": "1.2.3" },
|
||||
"stats": {},
|
||||
"createdAt": 0,
|
||||
@@ -187,6 +188,7 @@ Response:
|
||||
"slug": "gifgrep",
|
||||
"displayName": "GifGrep",
|
||||
"summary": "…",
|
||||
"topics": ["Productivity"],
|
||||
"tags": { "latest": "1.2.3" },
|
||||
"stats": {},
|
||||
"createdAt": 0,
|
||||
@@ -539,7 +541,7 @@ Query params:
|
||||
- `family` (optional): `skill`, `code-plugin`, or `bundle-plugin`
|
||||
- `channel` (optional): `official`, `community`, or `private`
|
||||
- `isOfficial` (optional): `true` or `false`
|
||||
- `sort` (optional): `updated` (default), `recommended`, `installs`
|
||||
- `sort` (optional): `updated` (default), `recommended`, `downloads`, legacy alias `installs`
|
||||
- `category` (optional): plugin category filter. Supported only when the
|
||||
request is scoped to plugin packages (`/api/v1/plugins`,
|
||||
`/api/v1/code-plugins`, `/api/v1/bundle-plugins`, or package endpoints with
|
||||
@@ -589,7 +591,7 @@ Query params:
|
||||
- `limit` (optional): integer (1-100)
|
||||
- `cursor` (optional): pagination cursor
|
||||
- `isOfficial` (optional): `true` or `false`
|
||||
- `sort` (optional): `recommended` (default), `installs`, `updated`
|
||||
- `sort` (optional): `recommended` (default), `downloads`, `updated`, legacy alias `installs`
|
||||
- `category` (optional): plugin category filter. Current values:
|
||||
`channels`, `models`, `memory`, `context`, `voice`, `media`, `web`,
|
||||
`tools`, `runtime`, `gateway`, `security`, `other`.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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.`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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<
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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) },
|
||||
];
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
});
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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
@@ -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";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user