// Reconciles DO Spaces against the documents table and reports/deletes orphaned objects — // files whose DB rows were removed before storage cleanup existed (deleted firms/cases/clients). // // Dry run (default): npx tsx scripts/sweep-orphaned-storage.ts // Actually delete: npx tsx scripts/sweep-orphaned-storage.ts --delete // // Only keys matching the document layout `//.` are eligible for // deletion; anything else in the bucket is reported but never touched. import path from 'node:path'; import { fileURLToPath } from 'node:url'; import dotenv from 'dotenv'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); dotenv.config({ path: path.resolve(__dirname, '../.env') }); import { getDb, getPool, documents } from '@lawdesk/db'; import { listAllKeys, deleteFile } from '../apps/api/src/lib/storage'; const DELETE = process.argv.includes('--delete'); const UUID = '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}'; const DOC_KEY = new RegExp(`^${UUID}/${UUID}/${UUID}(\\.[A-Za-z0-9]+)?$`, 'i'); async function main() { const db = getDb(); console.log('Listing bucket contents...'); const bucketKeys = await listAllKeys(); console.log(` ${bucketKeys.length} object(s) in bucket.`); const rows = await db.select({ storageKey: documents.storageKey }).from(documents); const dbKeys = new Set(rows.map((r) => r.storageKey)); console.log(` ${dbKeys.size} document row(s) in database.`); const orphans: string[] = []; const unrecognized: string[] = []; for (const key of bucketKeys) { if (dbKeys.has(key)) continue; if (DOC_KEY.test(key)) orphans.push(key); else unrecognized.push(key); } // Reverse check: DB rows whose file is missing from the bucket (report only). const bucketSet = new Set(bucketKeys); const missing = [...dbKeys].filter((k) => !bucketSet.has(k)); console.log(`\nOrphaned objects (in bucket, no DB row): ${orphans.length}`); for (const k of orphans) console.log(` ${k}`); if (unrecognized.length) { console.log(`\nUnrecognized keys (not document-shaped — never deleted): ${unrecognized.length}`); for (const k of unrecognized) console.log(` ${k}`); } if (missing.length) { console.log(`\nWARNING — DB rows whose file is MISSING from the bucket: ${missing.length}`); for (const k of missing) console.log(` ${k}`); } if (!DELETE) { console.log(`\nDry run — nothing deleted. Re-run with --delete to remove the ${orphans.length} orphan(s).`); } else { let deleted = 0; for (const key of orphans) { await deleteFile(key); deleted++; } console.log(`\nDeleted ${deleted} orphaned object(s).`); } await getPool().end(); } main().catch((err) => { console.error(err); process.exit(1); });