import { TRPCError } from "@trpc/server"; import { and, desc, eq, lt } from "drizzle-orm"; import { z } from "zod"; import { assets, backupsTable } from "@karakeep/db/schema"; import { BackupQueue } from "@karakeep/shared-server"; import { deleteAsset } from "@karakeep/shared/assetdb"; import { zBackupSchema } from "@karakeep/shared/types/backups"; import { AuthedContext } from ".."; export class Backup { private constructor( private ctx: AuthedContext, private backup: z.infer, ) {} static async fromId(ctx: AuthedContext, backupId: string): Promise { const backup = await ctx.db.query.backupsTable.findFirst({ where: and( eq(backupsTable.id, backupId), eq(backupsTable.userId, ctx.user.id), ), }); if (!backup) { throw new TRPCError({ code: "NOT_FOUND", message: "Backup not found", }); } return new Backup(ctx, backup); } private static fromData( ctx: AuthedContext, backup: z.infer, ): Backup { return new Backup(ctx, backup); } static async getAll(ctx: AuthedContext): Promise { const backups = await ctx.db.query.backupsTable.findMany({ where: eq(backupsTable.userId, ctx.user.id), orderBy: [desc(backupsTable.createdAt)], }); return backups.map((b) => new Backup(ctx, b)); } static async create(ctx: AuthedContext): Promise { const [backup] = await ctx.db .insert(backupsTable) .values({ userId: ctx.user.id, size: 0, bookmarkCount: 0, status: "pending", }) .returning(); return new Backup(ctx, backup!); } async triggerBackgroundJob({ delayMs, idempotencyKey, }: { delayMs?: number; idempotencyKey?: string } = {}): Promise { await BackupQueue.enqueue( { userId: this.ctx.user.id, backupId: this.backup.id, }, { delayMs, idempotencyKey, }, ); } /** * Generic update method for backup records */ async update( data: Partial<{ size: number; bookmarkCount: number; status: "pending" | "success" | "failure"; assetId: string | null; errorMessage: string | null; }>, ): Promise { await this.ctx.db .update(backupsTable) .set(data) .where( and( eq(backupsTable.id, this.backup.id), eq(backupsTable.userId, this.ctx.user.id), ), ); // Update local state this.backup = { ...this.backup, ...data }; } async delete(): Promise { if (this.backup.assetId) { // Delete asset await deleteAsset({ userId: this.ctx.user.id, assetId: this.backup.assetId, }); } await this.ctx.db.transaction(async (db) => { // Delete asset first if (this.backup.assetId) { await db .delete(assets) .where( and( eq(assets.id, this.backup.assetId), eq(assets.userId, this.ctx.user.id), ), ); } // Delete backup record await db .delete(backupsTable) .where( and( eq(backupsTable.id, this.backup.id), eq(backupsTable.userId, this.ctx.user.id), ), ); }); } /** * Finds backups older than the retention period */ static async findOldBackups( ctx: AuthedContext, retentionDays: number, ): Promise { const cutoffDate = new Date(); cutoffDate.setDate(cutoffDate.getDate() - retentionDays); const oldBackups = await ctx.db.query.backupsTable.findMany({ where: and( eq(backupsTable.userId, ctx.user.id), lt(backupsTable.createdAt, cutoffDate), ), }); return oldBackups.map((backup) => Backup.fromData(ctx, backup)); } asPublic(): z.infer { return this.backup; } get id() { return this.backup.id; } get assetId() { return this.backup.assetId; } }