Files
clawhub/convex/lib/skillStats.ts
T
Patrick Erichsen 0f84533e9c feat: add permanent skills.sh mirror storage (#3227)
* feat: add staged skills.sh mirror storage

* ci: allow guarded CLAW-563 Test deploy

* ci: expose guarded Test deploy diagnostics

* ci: defer branch guard to deploy step

* ci: deploy CLAW-563 PR head to Test

* ci: admit CLAW-563 PR Test job

* fix: make mirror source recovery durable

* ci: trigger labeled mirror load

* feat: activate mirror search queries

* fix: tighten mirror source typing

* fix: bypass protected Test mirror proof

* feat: attribute skill metrics by source

* feat: present stars as bookmarks

* style: format mirror proof changes

* fix: bypass protected mirror readback

* fix: resume mirror past missing scanner pages

* fix: fetch skills.sh mirror audits from api

* fix: validate structural skills.sh identities

* fix: resolve ambiguous skills.sh mirror identities

* feat: stabilize skills.sh mirror ingestion

* fix: account mirror identity conflicts in proof

* fix: quarantine invalid skills.sh detail ids

* fix: resume skills.sh mirror proof

* fix: preserve skills.sh mirror provenance

* fix: recover exact skills.sh mirror runs

* fix: recover stale skills.sh mirror runs

* fix: normalize skills.sh mirror topic facets

* feat: prove complete skills.sh leaderboard mirror

* fix: canonicalize skills.sh source page hashes

* test: enable skills.sh rollout in mirror tests

* ci: skip unrelated Test deploy pull requests

* fix: preserve Vercel preview marker in Test deploy

* fix: tighten Test deploy and metric reconciliation

* fix: bound mirror detail proof pages

* fix: delegate controlled mirror rate limits

* fix: preserve mirror reconciliation progress

* fix: release mirror retry responses

* fix: preserve stale mirror replay state

* fix: authenticate mirror source starts

* fix: delegate mirror identity rate limits

* ci: trigger mirror proof when labeled

* ci: couple mirror deploy and proof opt-in

* fix: admit permanent Vercel Test runtime

* fix: pass Test target to Vercel runtime

* test: align bookmark sync browser labels

* fix: preserve skills.sh source accounting

* fix: preflight active mirror runs

* fix: bind mirror snapshot accounting

* fix: reject truncated replay hashes

* fix: preserve live mirror overlay metadata
2026-07-24 14:32:00 -05:00

145 lines
4.7 KiB
TypeScript

import type { Doc, Id } from "../_generated/dataModel";
import type { MutationCtx } from "../_generated/server";
import { toDayKey } from "./leaderboards";
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));
}
/**
* 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: nonNegativeCount(skill.statsSkillsShInstalls),
openClawInstallsCurrent: readCanonicalStat(skill, "installsCurrent"),
openClawInstallsAllTime: readCanonicalStat(skill, "installsAllTime"),
githubStars: nonNegativeCount(skill.statsGithubStars),
bookmarks: readCanonicalStat(skill, "stars"),
};
}
export function readPublicDownloads(skill: SkillStatReadable): number {
return readCanonicalStat(skill, "downloads") + nonNegativeCount(skill.statsSkillsShInstalls);
}
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;
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,
});
}