Files
clawhub/convex/lib/skillSearchDigest.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

372 lines
12 KiB
TypeScript

import {
getCatalogTopicSlugs,
resolveCatalogTopics,
resolveStoredSkillCategories,
} from "clawhub-schema";
import type { Doc, Id } from "../_generated/dataModel";
import type { MutationCtx } from "../_generated/server";
import type { HydratableSkill, PublicPublisher } from "./public";
import { type PublicBrowseVersionState, resolvePublicBrowseVersionState } from "./publicBrowse";
import { getOwnerPublisher } from "./publishers";
import { computeRecommendationScore, RECOMMENDATION_SCORE_VERSION } from "./recommendationScore";
import { tokenize } from "./searchText";
import { readCanonicalStat } from "./skillStats";
function pick<T extends Record<string, unknown>, K extends keyof T>(obj: T, keys: K[]): Pick<T, K> {
return Object.fromEntries(keys.map((k) => [k, obj[k]])) as Pick<T, K>;
}
type SharedSkillKey = Extract<keyof Doc<"skills">, keyof Doc<"skillSearchDigest">>;
/**
* Fields shared 1:1 between `skills` and `skillSearchDigest` (same name,
* same type). Used by both `extractDigestFields` and `digestToHydratableSkill`
* so adding/removing a field here keeps them in sync.
*/
const SHARED_KEYS = [
"slug",
"displayName",
"summary",
"icon",
"ownerUserId",
"ownerPublisherId",
"canonicalSkillId",
"forkOf",
"latestVersionId",
"installKind",
"githubHasSkillCard",
"githubCurrentStatus",
"githubScanStatus",
"latestVersionSummary",
"tags",
"categories",
"topics",
"badges",
"stats",
"statsDownloads",
"statsStars",
"statsInstallsCurrent",
"statsInstallsAllTime",
"statsSkillsShInstalls",
"statsGithubStars",
"softDeletedAt",
"moderationStatus",
"moderationFlags",
"moderationVerdict",
"moderationReason",
"isSuspicious",
"createdAt",
"updatedAt",
] as const satisfies readonly SharedSkillKey[];
/** Fields stored in the skillSearchDigest table. */
export type SkillSearchDigestFields = Pick<Doc<"skills">, (typeof SHARED_KEYS)[number]> & {
skillId: Id<"skills">;
latestVersionSkillId?: Id<"skills">;
normalizedSlug?: string;
normalizedSlugFirstToken?: string;
normalizedDisplayName?: string;
normalizedDisplayNameFirstToken?: string;
ownerHandle?: string;
ownerKind?: "user" | "org";
ownerName?: string;
ownerDisplayName?: string;
ownerImage?: string;
recommendedScore?: number;
recommendedScoreVersion?: number;
publicVersion?: PublicBrowseVersionState;
};
/** Pick the subset of fields from a full skill doc needed for the digest. */
export function extractDigestFields(skill: Doc<"skills">): SkillSearchDigestFields {
const statsDownloads = readCanonicalStat(skill, "downloads");
const statsStars = readCanonicalStat(skill, "stars");
const statsInstallsCurrent = readCanonicalStat(skill, "installsCurrent");
const statsInstallsAllTime = readCanonicalStat(skill, "installsAllTime");
const inferenceCurrent =
Boolean(skill.latestVersionId) && skill.latestVersionId === skill.inferredFromVersionId;
return {
...pick(skill, [...SHARED_KEYS]),
categories: resolveStoredSkillCategories(skill),
topics: resolveCatalogTopics({
declared: skill.topics,
inferred: skill.inferredTopics,
inferenceCurrent,
}),
statsDownloads,
statsStars,
statsInstallsCurrent,
statsInstallsAllTime,
recommendedScore: computeRecommendationScore(
{
downloads: statsDownloads,
installs: statsInstallsAllTime,
stars: statsStars,
},
{
createdAt: skill.createdAt,
updatedAt: skill.updatedAt,
},
),
recommendedScoreVersion: RECOMMENDATION_SCORE_VERSION,
skillId: skill._id,
normalizedSlug: normalizeSkillSearchText(skill.slug),
normalizedSlugFirstToken: getFirstSearchToken(skill.slug),
normalizedDisplayName: normalizeSkillSearchText(skill.displayName),
normalizedDisplayNameFirstToken: getFirstSearchToken(skill.displayName),
};
}
export async function extractValidatedDigestFields(
ctx: Pick<MutationCtx, "db">,
skill: Doc<"skills">,
): Promise<SkillSearchDigestFields> {
const fields = extractDigestFields(skill);
const version = skill.latestVersionId ? await ctx.db.get(skill.latestVersionId) : null;
const publicVersion = await resolvePublicBrowseVersionState(ctx, skill, {
latestVersion: version,
});
if (!version || version.softDeletedAt || version.skillId !== skill._id) {
return {
...fields,
latestVersionId: undefined,
latestVersionSkillId: undefined,
latestVersionSummary: undefined,
publicVersion,
};
}
return { ...fields, latestVersionSkillId: version.skillId, publicVersion };
}
export function normalizeSkillSearchText(value: string) {
return value.trim().toLowerCase();
}
export function getFirstSearchToken(value: string) {
return tokenize(value)[0];
}
/**
* Map a digest row to the HydratableSkill shape expected by toPublicSkill /
* isPublicSkillDoc / isSkillSuspicious. Fully type-checked: if
* HydratableSkill gains a field the digest doesn't carry, this will fail
* to compile.
*/
export function digestToHydratableSkill(digest: Doc<"skillSearchDigest">): HydratableSkill {
return {
...pick(digest, [...SHARED_KEYS]),
_id: digest.skillId,
_creationTime: digest.createdAt,
};
}
/** Insert or update the digest row for a skill. Skips the write when no fields changed. */
export async function upsertSkillSearchDigest(
ctx: Pick<MutationCtx, "db">,
fields: SkillSearchDigestFields,
) {
const existing = await ctx.db
.query("skillSearchDigest")
.withIndex("by_skill", (q) => q.eq("skillId", fields.skillId))
.unique();
if (existing) {
if (hasDigestChanged(existing, fields)) {
await ctx.db.patch(existing._id, fields);
}
} else {
await ctx.db.insert("skillSearchDigest", fields);
}
if ((existing?.topics?.length ?? 0) > 0 || (fields.topics?.length ?? 0) > 0) {
await syncSkillTopicSearchDigests(ctx, fields);
}
await syncCuratedSkillSearchDigest(
ctx,
fields,
Boolean(existing?.badges?.official || existing?.badges?.highlighted),
);
}
async function syncCuratedSkillSearchDigest(
ctx: Pick<MutationCtx, "db">,
fields: SkillSearchDigestFields,
wasCurated: boolean,
) {
const isCurated = Boolean(fields.badges?.official || fields.badges?.highlighted);
if (!isCurated && !wasCurated) return;
const existing = await ctx.db
.query("curatedSkillSearchDigest")
.withIndex("by_skill", (q) => q.eq("skillId", fields.skillId))
.unique();
if (!isCurated) {
if (existing) await ctx.db.delete(existing._id);
return;
}
const next = {
skillId: fields.skillId,
slug: fields.slug,
displayName: fields.displayName,
summary: fields.summary,
categories: fields.categories,
topics: fields.topics,
statsDownloads: fields.statsDownloads,
statsStars: fields.statsStars,
statsInstallsAllTime: fields.statsInstallsAllTime,
recommendedScore: fields.recommendedScore,
softDeletedAt: fields.softDeletedAt,
isSuspicious: fields.isSuspicious,
createdAt: fields.createdAt,
updatedAt: fields.updatedAt,
};
if (existing) {
if (hasDigestChanged(existing, next)) await ctx.db.patch(existing._id, next);
} else {
await ctx.db.insert("curatedSkillSearchDigest", next);
}
}
async function syncSkillTopicSearchDigests(
ctx: Pick<MutationCtx, "db">,
fields: SkillSearchDigestFields,
) {
const existing = await ctx.db
.query("skillTopicSearchDigest")
.withIndex("by_skill", (q) => q.eq("skillId", fields.skillId))
.collect();
const nextByTopic = new Map(
getCatalogTopicSlugs(fields.topics).map((topic) => [
topic,
{
skillId: fields.skillId,
topic,
softDeletedAt: fields.softDeletedAt,
isSuspicious: fields.isSuspicious,
normalizedDisplayName: fields.normalizedDisplayName,
statsDownloads: fields.statsDownloads,
statsStars: fields.statsStars,
statsInstallsAllTime: fields.statsInstallsAllTime,
recommendedScore: fields.recommendedScore,
createdAt: fields.createdAt,
updatedAt: fields.updatedAt,
},
]),
);
for (const row of existing) {
const next = nextByTopic.get(row.topic);
if (!next) {
await ctx.db.delete(row._id);
continue;
}
if (hasDigestChanged(row, next)) {
await ctx.db.patch(row._id, next);
}
nextByTopic.delete(row.topic);
}
for (const next of nextByTopic.values()) {
await ctx.db.insert("skillTopicSearchDigest", next);
}
}
export async function deleteSkillSearchDigests(
ctx: Pick<MutationCtx, "db">,
skillId: Id<"skills">,
) {
const digest = await ctx.db
.query("skillSearchDigest")
.withIndex("by_skill", (q) => q.eq("skillId", skillId))
.unique();
if (digest) await ctx.db.delete(digest._id);
for (const topicDigest of await ctx.db
.query("skillTopicSearchDigest")
.withIndex("by_skill", (q) => q.eq("skillId", skillId))
.collect()) {
await ctx.db.delete(topicDigest._id);
}
const curatedDigest = await ctx.db
.query("curatedSkillSearchDigest")
.withIndex("by_skill", (q) => q.eq("skillId", skillId))
.unique();
if (curatedDigest) await ctx.db.delete(curatedDigest._id);
}
export async function syncSkillSearchDigestForSkill(
ctx: Pick<MutationCtx, "db">,
skill: Doc<"skills"> | null | undefined,
) {
if (!skill) return;
const fields = await extractValidatedDigestFields(ctx, skill);
const owner = await getOwnerPublisher(ctx, {
ownerPublisherId: skill.ownerPublisherId,
ownerUserId: skill.ownerUserId,
});
await upsertSkillSearchDigest(ctx, {
...fields,
ownerHandle: owner?.handle ?? "",
ownerKind: owner?.kind,
ownerName: owner?.linkedUserId ? owner.handle : undefined,
ownerDisplayName: owner?.displayName,
ownerImage: owner?.image,
});
}
/** Compare new fields against existing row. Returns true if any field differs. */
function hasDigestChanged(
existing: Record<string, unknown>,
fields: Record<string, unknown>,
): boolean {
for (const key of Object.keys(fields)) {
const oldVal = (existing as Record<string, unknown>)[key];
const newVal = (fields as Record<string, unknown>)[key];
if (oldVal === newVal) continue;
if (JSON.stringify(oldVal) !== JSON.stringify(newVal)) return true;
}
return false;
}
/**
* Extract pre-resolved owner info from a digest row.
* Returns null if the owner fields haven't been backfilled yet.
*/
export function digestToOwnerInfo(
digest: Pick<
Doc<"skillSearchDigest">,
| "ownerHandle"
| "ownerKind"
| "ownerName"
| "ownerDisplayName"
| "ownerImage"
| "ownerUserId"
| "ownerPublisherId"
>,
): { ownerHandle: string | null; owner: PublicPublisher | null } | null {
if (digest.ownerHandle === undefined) return null;
// Empty string means backfilled but owner has no handle.
// Use userId as fallback handle, matching the live getOwnerInfo path.
const handle = digest.ownerHandle || undefined;
const fallbackHandle = handle ?? String(digest.ownerPublisherId ?? digest.ownerUserId);
const resolvedHandle = handle ?? fallbackHandle;
// Determine if we have real profile data (deactivated/deleted owners have
// all profile fields undefined, while handle-less visible owners still have
// name/displayName/image populated).
const hasProfileData =
digest.ownerName !== undefined ||
digest.ownerDisplayName !== undefined ||
digest.ownerImage !== undefined;
return {
ownerHandle: fallbackHandle,
owner:
handle || hasProfileData
? {
_id: digest.ownerPublisherId ?? ("publishers:missing" as Id<"publishers">),
_creationTime: 0,
handle: resolvedHandle,
displayName: digest.ownerDisplayName ?? digest.ownerName ?? resolvedHandle,
image: digest.ownerImage,
bio: undefined,
kind: digest.ownerKind ?? "user",
linkedUserId: digest.ownerKind === "org" ? undefined : digest.ownerUserId,
}
: null,
};
}