// Wipe an account from safekeeper's control plane and storage. // // After a wipe: // - every CAR file under repos/{did}/ is deleted from storage // - every blob under blobs/{did}/ is deleted from storage // - the account, tracked_account, repo_backup, blob_backup rows for the DID // are hard-deleted // - the DID is inserted into blocked_account so future scheduler ticks // ignore it even if a PDS still lists it // // This is destructive and irreversible from within the panel (unblock is a // manual DB delete). The controller layer is responsible for confirming // intent — this function assumes the caller has already done that. import { eq } from 'drizzle-orm'; import db from '../database'; import { account, blob_backup, blocked_account, repo_backup, tracked_account } from '../database/schema'; import { storage } from '../storage'; export interface WipeSummary { repoBackupsDeleted: number; blobBackupsDeleted: number; bytesFreed: number; } export async function wipeAccount( did: string, reason: string | null ): Promise { const s = storage(); // Remove the whole per-DID directories in one shot — cheaper than iterating // each file, and correct even if the DB and disk have drifted (any orphan // files under repos/{did} or blobs/{did} also go). LocalStorage.delete // uses `rm -rf`, S3 impls would need a list+delete-many. await s.delete(`repos/${did}`).catch(() => {}); await s.delete(`blobs/${did}`).catch(() => {}); // Tally what we're about to remove for the return summary. Include pruned // snapshots because they were once real bytes we captured (even if the // file is gone). const repos = await db .select({ id: repo_backup.id, size: repo_backup.size_bytes }) .from(repo_backup) .where(eq(repo_backup.account_did, did)); const blobs = await db .select({ cid: blob_backup.cid, size: blob_backup.size_bytes }) .from(blob_backup) .where(eq(blob_backup.account_did, did)); const summary: WipeSummary = { repoBackupsDeleted: repos.length, blobBackupsDeleted: blobs.length, bytesFreed: repos.reduce((n, r) => n + r.size, 0) + blobs.reduce((n, b) => n + b.size, 0) }; // Child rows first (they FK-reference account), then parent, then insert // into the blocklist. bun:sqlite doesn't expose a great transaction API // via drizzle-orm; a scheduler tick can't interleave with us here because // runBackupCycle is single-flight via the in-process mutex. await db.delete(blob_backup).where(eq(blob_backup.account_did, did)); await db.delete(repo_backup).where(eq(repo_backup.account_did, did)); await db.delete(tracked_account).where(eq(tracked_account.did, did)); await db.delete(account).where(eq(account.did, did)); await db .insert(blocked_account) .values({ did, reason }) .onConflictDoUpdate({ target: blocked_account.did, set: { reason } }); return summary; }