clawhub/convex/githubBackups.ts
2026-04-29 23:33:25 -07:00

206 lines
5.8 KiB
TypeScript

import { v } from "convex/values";
import { internal } from "./_generated/api";
import type { Id } from "./_generated/dataModel";
import { action, internalMutation, internalQuery } from "./functions";
import { assertRole, requireUserFromAction } from "./lib/access";
const DEFAULT_BATCH_SIZE = 50;
const MAX_BATCH_SIZE = 200;
const SYNC_STATE_KEY = "default";
type BackupPageItem =
| {
kind: "ok";
skillId: Id<"skills">;
versionId: Id<"skillVersions">;
slug: string;
displayName: string;
version: string;
ownerHandle: string;
publishedAt: number;
}
| { kind: "missingLatestVersion"; skillId: Id<"skills"> }
| { kind: "missingOwner"; skillId: Id<"skills">; ownerUserId: Id<"users"> };
type BackupPageResult = {
items: BackupPageItem[];
cursor: string | null;
isDone: boolean;
};
type BackupSyncState = {
cursor: string | null;
pruneCursor: string | null;
};
export type SyncGitHubBackupsResult = {
stats: {
skillsScanned: number;
skillsSkipped: number;
skillsBackedUp: number;
skillsDeleted: number;
skillsMissingVersion: number;
skillsMissingOwner: number;
errors: number;
};
cursor: string | null;
pruneCursor: string | null;
isDone: boolean;
};
export const getGitHubBackupPageInternal = internalQuery({
args: {
cursor: v.optional(v.string()),
batchSize: v.optional(v.number()),
},
handler: async (ctx, args): Promise<BackupPageResult> => {
const batchSize = clampInt(args.batchSize ?? DEFAULT_BATCH_SIZE, 1, MAX_BATCH_SIZE);
let pageResult;
try {
pageResult = await ctx.db
.query("skillSearchDigest")
.order("asc")
.paginate({ cursor: args.cursor ?? null, numItems: batchSize });
} catch (error) {
if (!args.cursor || !isStaleCursorError(error)) throw error;
pageResult = await ctx.db
.query("skillSearchDigest")
.order("asc")
.paginate({ cursor: null, numItems: batchSize });
}
const items: BackupPageItem[] = [];
for (const digest of pageResult.page) {
if (!isPubliclyAvailableSkill(digest)) continue;
if (!digest.latestVersionId || !digest.latestVersionSummary) {
items.push({ kind: "missingLatestVersion", skillId: digest.skillId });
continue;
}
if (digest.ownerHandle === undefined) {
items.push({
kind: "missingOwner",
skillId: digest.skillId,
ownerUserId: digest.ownerUserId,
});
continue;
}
const ownerHandle =
digest.ownerHandle || String(digest.ownerPublisherId ?? digest.ownerUserId);
items.push({
kind: "ok",
skillId: digest.skillId,
versionId: digest.latestVersionId,
slug: digest.slug,
displayName: digest.displayName,
version: digest.latestVersionSummary.version,
ownerHandle,
publishedAt: digest.latestVersionSummary.createdAt,
});
}
return { items, cursor: pageResult.continueCursor, isDone: pageResult.isDone };
},
});
function isPubliclyAvailableSkill(skill: {
softDeletedAt?: number;
moderationStatus?: string | null;
}) {
if (skill.softDeletedAt) return false;
return (
skill.moderationStatus === undefined ||
skill.moderationStatus === null ||
skill.moderationStatus === "active"
);
}
function isStaleCursorError(error: unknown) {
const message =
typeof error === "string"
? error
: error && typeof error === "object" && "message" in error
? String((error as { message?: unknown }).message)
: "";
return (
message.includes("Failed to parse cursor") ||
message.includes("cursor is from a different query")
);
}
export const getGitHubBackupSyncStateInternal = internalQuery({
args: {},
handler: async (ctx): Promise<BackupSyncState> => {
const state = await ctx.db
.query("githubBackupSyncState")
.withIndex("by_key", (q) => q.eq("key", SYNC_STATE_KEY))
.unique();
return { cursor: state?.cursor ?? null, pruneCursor: state?.pruneCursor ?? null };
},
});
export const setGitHubBackupSyncStateInternal = internalMutation({
args: {
cursor: v.optional(v.string()),
pruneCursor: v.optional(v.string()),
},
handler: async (ctx, args) => {
const now = Date.now();
const state = await ctx.db
.query("githubBackupSyncState")
.withIndex("by_key", (q) => q.eq("key", SYNC_STATE_KEY))
.unique();
if (!state) {
await ctx.db.insert("githubBackupSyncState", {
key: SYNC_STATE_KEY,
cursor: args.cursor,
pruneCursor: args.pruneCursor,
updatedAt: now,
});
return { ok: true as const };
}
await ctx.db.patch(state._id, {
cursor: args.cursor,
pruneCursor: args.pruneCursor,
updatedAt: now,
});
return { ok: true as const };
},
});
export const syncGitHubBackups: ReturnType<typeof action> = action({
args: {
dryRun: v.optional(v.boolean()),
batchSize: v.optional(v.number()),
maxBatches: v.optional(v.number()),
pruneBatchSize: v.optional(v.number()),
resetCursor: v.optional(v.boolean()),
},
handler: async (ctx, args): Promise<SyncGitHubBackupsResult> => {
const { user } = await requireUserFromAction(ctx);
assertRole(user, ["admin"]);
if (args.resetCursor && !args.dryRun) {
await ctx.runMutation(internal.githubBackups.setGitHubBackupSyncStateInternal, {
cursor: undefined,
pruneCursor: undefined,
});
}
return ctx.runAction(internal.githubBackupsNode.syncGitHubBackupsInternal, {
dryRun: args.dryRun,
batchSize: args.batchSize,
maxBatches: args.maxBatches,
pruneBatchSize: args.pruneBatchSize,
}) as Promise<SyncGitHubBackupsResult>;
},
});
function clampInt(value: number, min: number, max: number) {
return Math.max(min, Math.min(max, Math.floor(value)));
}