/** * AI Software Factory — S3 orphan-object check (TASK-0104 §1/§3). * * The acceptance-criterion check script: reports how many objects under the * files bucket's `files/` prefix have NO matching `File`/`FileVersion` ledger * row ("orphan keys"). After a correct purge/replace workflow this reports * `0`. * * pnpm storage:orphans # DRY-RUN — report orphans (default) * pnpm storage:orphans --purge # delete orphans (manual action) * pnpm storage:orphans --grace-days 0 # no grace window (default for --purge) * * The daily `maintenance:orphans` worker job runs the SAME core * (`apps/worker/src/processors/files/cleanup.ts::runOrphanSweep`) with a 7-day * grace + deletes; this CLI is the on-demand/manual entry point (and the * integration/E2E acceptance check). * * Reads `.env` (repo root) for `DATABASE_URL` + the S3 `files/` bucket config. */ import { readFileSync, existsSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { PrismaClient } from "@prisma/client"; import { S3Client, type S3Config } from "@factory/storage"; // The worker's orphan core (imports only @prisma/client + @factory/storage — // resolvable from the root via tsx; see cleanup.ts dependency note). import { runOrphanSweep, ORPHAN_GRACE_DAYS_DEFAULT } from "../apps/worker/src/processors/files/cleanup.js"; const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), ".."); /** Minimal `.env` loader (KEY=VALUE lines, `#` comments) — mirrors * scripts/minio-init.ts (the root has no dotenv dep). */ function loadDotEnv(): Record { const out: Record = {}; const path = join(REPO_ROOT, ".env"); if (!existsSync(path)) return out; for (const line of readFileSync(path, "utf8").split("\n")) { const t = line.trim(); if (t === "" || t.startsWith("#")) continue; const eq = t.indexOf("="); if (eq <= 0) continue; out[t.slice(0, eq).trim()] = t.slice(eq + 1).trim(); } return out; } const fileEnv = loadDotEnv(); const env = (key: string, fallback: string): string => process.env[key] ?? fileEnv[key] ?? fallback; // Prisma reads DATABASE_URL from the environment (not .env automatically when // a client is built programmatically) — seed it from the file if absent. if (!process.env.DATABASE_URL) process.env.DATABASE_URL = fileEnv.DATABASE_URL; interface Args { purge: boolean; graceDays: number; fileId?: string; } function parseArgs(argv: string[]): Args { const args: Args = { purge: false, graceDays: ORPHAN_GRACE_DAYS_DEFAULT }; for (let i = 0; i < argv.length; i++) { const a = argv[i]; if (a === "--purge") args.purge = true; else if (a === "--grace-days") args.graceDays = Number(argv[++i] ?? "0"); else if (a === "--file") args.fileId = argv[++i]; } // Default: a manual --purge acts immediately (grace 0); a dry-run reports // every orphan regardless of age (the "reports 0" acceptance check). if (args.purge && !argv.includes("--grace-days")) args.graceDays = 0; return args; } async function main(): Promise { const args = parseArgs(process.argv.slice(2)); const config: S3Config = { // Mirrors apps/api/src/modules/files/s3.ts makeFilesClient — the same // bucket the pipeline writes to. endpoint: env("S3_ENDPOINT", "http://localhost:9000").replace(/\/+$/, ""), region: env("S3_REGION", "us-east-1"), bucket: env("S3_BUCKET_FILES", env("S3_BUCKET", "factory-files")), accessKeyId: env("S3_ACCESS_KEY", "minioadmin"), secretAccessKey: env("S3_SECRET_KEY", "minioadmin"), forcePathStyle: true, }; const s3 = new S3Client({ config }); const prisma = new PrismaClient(); const consoleLog = { info: (o: Record) => console.log(JSON.stringify(o)), warn: (o: Record) => console.warn(JSON.stringify(o)), }; const report = await runOrphanSweep({ s3, prisma, dryRun: !args.purge, graceDays: args.graceDays, fileId: args.fileId, log: consoleLog, }); console.log( `\nstorage:orphans — ${args.purge ? "PURGE" : "dry-run"}${args.fileId ? ` (file ${args.fileId})` : " (whole bucket)"}: ` + `${report.orphansFound} orphan key(s) under files/ ` + `(scanned ${report.scannedKeys}, deleted ${report.deleted}, retained ${report.retained})` ); for (const k of report.keys) console.log(` ${args.purge ? "deleted " : "orphan "}${k}`); if (report.orphansFound === 0) console.log(" ✓ zero orphan keys"); await prisma.$disconnect(); process.exit(report.orphansFound > 0 && !args.purge ? 1 : 0); } main().catch((err) => { console.error("storage:orphans failed:", err); process.exit(2); });