Files
Patrick Erichsen 9784710147 fix: separate skill metrics by source (#3228)
* fix: use bookmark icons consistently

* fix: separate skill metrics by source
2026-07-29 22:41:06 -07:00

151 lines
4.9 KiB
TypeScript

import type { Doc, Id } from "../_generated/dataModel";
import type { MutationCtx } from "../_generated/server";
import { toDayKey } from "./leaderboards";
import { assertRankingMetricWritesAllowed } from "./rankingMetricsImportLock";
type SkillStatDeltas = {
downloads?: number;
stars?: number;
installsCurrent?: number;
installsAllTime?: number;
};
export type SkillStatReadable = {
stats: Partial<
Pick<Doc<"skills">["stats"], "downloads" | "stars" | "installsCurrent" | "installsAllTime">
>;
statsDownloads?: number;
statsStars?: number;
statsInstallsCurrent?: number;
statsInstallsAllTime?: number;
statsSkillsShInstalls?: number;
statsGithubStars?: number;
};
type ExternalSkillMetricSnapshot = {
skillsShInstalls?: number;
githubStars?: number;
};
function nonNegativeCount(value: number | undefined): number {
if (typeof value !== "number" || !Number.isFinite(value)) return 0;
return Math.max(0, Math.trunc(value));
}
function optionalNonNegativeCount(value: number | undefined): number | null {
return value === undefined ? null : nonNegativeCount(value);
}
/**
* Read the canonical value of a migrated stat field from a skill document.
*
* Top-level fields (`statsDownloads`, etc.) are the source of truth — they are
* indexable and kept up-to-date by the event pipeline. The nested `stats.*`
* fields are only used as a fallback for pre-migration documents where the
* top-level field is still `undefined`.
*
* All code that reads a migrated stat value should go through this function
* rather than accessing `skill.stats.*` directly.
*/
export function readCanonicalStat(
skill: SkillStatReadable,
field: "downloads" | "stars" | "installsCurrent" | "installsAllTime",
): number {
const topLevelKey = `stats${field[0].toUpperCase()}${field.slice(1)}` as
| "statsDownloads"
| "statsStars"
| "statsInstallsCurrent"
| "statsInstallsAllTime";
return typeof skill[topLevelKey] === "number" ? skill[topLevelKey]! : (skill.stats[field] ?? 0);
}
export function readSkillMetricSources(skill: SkillStatReadable) {
return {
clawHubDownloads: readCanonicalStat(skill, "downloads"),
skillsShInstalls: optionalNonNegativeCount(skill.statsSkillsShInstalls),
openClawInstallsCurrent: readCanonicalStat(skill, "installsCurrent"),
openClawInstallsAllTime: readCanonicalStat(skill, "installsAllTime"),
githubStars: optionalNonNegativeCount(skill.statsGithubStars),
bookmarks: readCanonicalStat(skill, "stars"),
};
}
export function readPublicDownloads(skill: SkillStatReadable): number {
return readCanonicalStat(skill, "downloads");
}
export function buildExternalSkillMetricPatch(snapshot: ExternalSkillMetricSnapshot) {
return {
...(snapshot.skillsShInstalls === undefined
? {}
: { statsSkillsShInstalls: nonNegativeCount(snapshot.skillsShInstalls) }),
...(snapshot.githubStars === undefined
? {}
: { statsGithubStars: nonNegativeCount(snapshot.githubStars) }),
};
}
export function applySkillStatDeltas(skill: Doc<"skills">, deltas: SkillStatDeltas) {
const currentDownloads = readCanonicalStat(skill, "downloads");
const currentStars = readCanonicalStat(skill, "stars");
const currentInstallsCurrent = readCanonicalStat(skill, "installsCurrent");
const currentInstallsAllTime = readCanonicalStat(skill, "installsAllTime");
const nextDownloads = Math.max(0, currentDownloads + (deltas.downloads ?? 0));
const nextStars = Math.max(0, currentStars + (deltas.stars ?? 0));
const nextInstallsCurrent = Math.max(0, currentInstallsCurrent + (deltas.installsCurrent ?? 0));
const nextInstallsAllTime = Math.max(0, currentInstallsAllTime + (deltas.installsAllTime ?? 0));
return {
statsDownloads: nextDownloads,
statsStars: nextStars,
statsInstallsCurrent: nextInstallsCurrent,
statsInstallsAllTime: nextInstallsAllTime,
stats: {
...skill.stats,
downloads: nextDownloads,
stars: nextStars,
installsCurrent: nextInstallsCurrent,
installsAllTime: nextInstallsAllTime,
},
};
}
export async function bumpDailySkillStats(
ctx: MutationCtx,
params: {
skillId: Id<"skills">;
now: number;
downloads?: number;
installs?: number;
},
) {
const downloads = params.downloads ?? 0;
const installs = params.installs ?? 0;
if (downloads === 0 && installs === 0) return;
assertRankingMetricWritesAllowed();
const day = toDayKey(params.now);
const existing = await ctx.db
.query("skillDailyStats")
.withIndex("by_skill_day", (q) => q.eq("skillId", params.skillId).eq("day", day))
.unique();
if (existing) {
await ctx.db.patch(existing._id, {
downloads: Math.max(0, existing.downloads + downloads),
installs: Math.max(0, existing.installs + installs),
updatedAt: params.now,
});
return;
}
await ctx.db.insert("skillDailyStats", {
skillId: params.skillId,
day,
downloads: Math.max(0, downloads),
installs: Math.max(0, installs),
updatedAt: params.now,
});
}