Files
Patrick Erichsen a58294361b fix: show honest canonical trending states (#3294)
* fix: show honest canonical trending states

* ci: guard CLAW-602 permanent Test deploy

* fix: fail closed when trending discovery is unavailable

* fix: preserve trending rows on pagination errors

* feat: build native rolling trending feed

* fix: decouple native trending from skills.sh

* test: cover native trending rollout independence
2026-07-29 23:45:14 -07:00

152 lines
5.0 KiB
TypeScript

import { v } from "convex/values";
import type { Doc } from "./_generated/dataModel";
import type { MutationCtx } from "./_generated/server";
import { internalMutation, mutation, query } from "./functions";
import { getOptionalActiveAuthUserId, requireUser } from "./lib/access";
import { toPublicSkill } from "./lib/public";
import { bumpLiveHourlySkillStats, ensureHourlyStatsState } from "./lib/skillHourlyStats";
import { applySkillStatDeltas } from "./lib/skillStats";
import { adjustUserSkillStatsForSkillChange } from "./lib/userSkillStats";
async function applyStarDelta(
ctx: Pick<MutationCtx, "db">,
skill: Doc<"skills">,
delta: 1 | -1,
occurredAt: number,
hourlyState?: { activeGeneration: number },
) {
const patch = applySkillStatDeltas(skill, { stars: delta });
const nextSkill = { ...skill, ...patch };
await ctx.db.patch(skill._id, patch);
await adjustUserSkillStatsForSkillChange(ctx, skill, nextSkill);
if (hourlyState) {
await bumpLiveHourlySkillStats(
ctx,
{
skillId: skill._id,
occurredAt,
bookmarks: delta,
},
{ state: hourlyState },
);
}
}
export const isStarred = query({
args: { skillId: v.id("skills") },
handler: async (ctx, args) => {
const userId = await getOptionalActiveAuthUserId(ctx);
if (!userId) return false;
const existing = await ctx.db
.query("stars")
.withIndex("by_skill_user", (q) => q.eq("skillId", args.skillId).eq("userId", userId))
.unique();
return Boolean(existing);
},
});
export const toggle = mutation({
args: { skillId: v.id("skills") },
handler: async (ctx, args) => {
const { userId } = await requireUser(ctx);
const skill = await ctx.db.get(args.skillId);
if (!skill) throw new Error("Skill not found");
const existing = await ctx.db
.query("stars")
.withIndex("by_skill_user", (q) => q.eq("skillId", args.skillId).eq("userId", userId))
.unique();
if (existing) {
await ctx.db.delete(existing._id);
const hourlyState =
existing.hourlyStatsRecordedAt === undefined
? undefined
: await ensureHourlyStatsState(ctx);
await applyStarDelta(ctx, skill, -1, existing.createdAt, hourlyState);
return { starred: false };
}
if (skill.softDeletedAt) throw new Error("Skill not found");
const hourlyState = await ensureHourlyStatsState(ctx);
const createdAt = Date.now();
await ctx.db.insert("stars", {
skillId: args.skillId,
userId,
createdAt,
hourlyStatsRecordedAt: createdAt,
});
await applyStarDelta(ctx, skill, 1, createdAt, hourlyState);
return { starred: true };
},
});
export const listByUser = query({
args: { userId: v.id("users"), limit: v.optional(v.number()) },
handler: async (ctx, args) => {
const limit = args.limit ?? 50;
const stars = await ctx.db
.query("stars")
.withIndex("by_user", (q) => q.eq("userId", args.userId))
.order("desc")
.take(limit);
const skills: NonNullable<ReturnType<typeof toPublicSkill>>[] = [];
for (const star of stars) {
const skill = await ctx.db.get(star.skillId);
const publicSkill = toPublicSkill(skill);
if (!publicSkill) continue;
skills.push(publicSkill);
}
return skills;
},
});
export const addStarInternal = internalMutation({
args: { userId: v.id("users"), skillId: v.id("skills") },
handler: async (ctx, args) => {
const skill = await ctx.db.get(args.skillId);
if (!skill || skill.softDeletedAt) throw new Error("Skill not found");
const existing = await ctx.db
.query("stars")
.withIndex("by_skill_user", (q) => q.eq("skillId", args.skillId).eq("userId", args.userId))
.unique();
if (existing) return { ok: true as const, starred: true, alreadyStarred: true };
const hourlyState = await ensureHourlyStatsState(ctx);
const createdAt = Date.now();
await ctx.db.insert("stars", {
skillId: args.skillId,
userId: args.userId,
createdAt,
hourlyStatsRecordedAt: createdAt,
});
await applyStarDelta(ctx, skill, 1, createdAt, hourlyState);
return { ok: true as const, starred: true, alreadyStarred: false };
},
});
export const removeStarInternal = internalMutation({
args: { userId: v.id("users"), skillId: v.id("skills") },
handler: async (ctx, args) => {
const skill = await ctx.db.get(args.skillId);
if (!skill) throw new Error("Skill not found");
const existing = await ctx.db
.query("stars")
.withIndex("by_skill_user", (q) => q.eq("skillId", args.skillId).eq("userId", args.userId))
.unique();
if (!existing) return { ok: true as const, unstarred: false, alreadyUnstarred: true };
await ctx.db.delete(existing._id);
const hourlyState =
existing.hourlyStatsRecordedAt === undefined ? undefined : await ensureHourlyStatsState(ctx);
await applyStarDelta(ctx, skill, -1, existing.createdAt, hourlyState);
return { ok: true as const, unstarred: true, alreadyUnstarred: false };
},
});