1
36import type { PrismaClient } from "@prisma/client";
37import { S3Client } from "@factory/storage";
38
39
41export interface OrphanLogger {
42 info(obj: Record<string, unknown>, msg?: string): void;
43 warn(obj: Record<string, unknown>, msg?: string): void;
44}
45
46const consoleLogger: OrphanLogger = {
47 info: (obj, msg) => console.log(msg ? `${msg} ${JSON.stringify(obj)}` : JSON.stringify(obj)),
48 warn: (obj, msg) => console.warn(msg ? `${msg} ${JSON.stringify(obj)}` : JSON.stringify(obj)),
49};
50
51
52export const ORPHAN_GRACE_DAYS_DEFAULT = 7;
53
54export interface OrphanSweeperOptions {
55 s3: S3Client;
56 prisma: PrismaClient;
57
59 graceDays?: number;
60
62 dryRun?: boolean;
63
65 maxObjects?: number;
66
70 fileId?: string;
71
72 log?: OrphanLogger;
73}
74
75export interface OrphanReport {
76 scannedKeys: number;
77
78 fileId?: string;
79 orphansFound: number;
80 deleted: number;
81
83 retained: number;
84 dryRun: boolean;
85 keys: string[];
86}
87
88export interface ParsedOrphanKey {
89 workspaceId: string;
90 fileId: string;
91 version: number | null;
92 name: string;
93}
94
95
101export function parseOrphanKey(key: string): ParsedOrphanKey | null {
102 const m = /^files\/([^/]+)\/([^/]+)\/(\d+)\/(.+)$/.exec(key);
103 if (!m) return null;
104 const version = Number(m[3]);
105 return {
106 workspaceId: m[1]!,
107 fileId: m[2]!,
108 version: Number.isSafeInteger(version) && version >= 1 ? version : null,
109 name: m[4]!,
110 };
111}
112
113
122async function loadLiveLedger(prisma: PrismaClient): Promise<Set<string>> {
123 const [files, versions] = await Promise.all([
124 prisma.file.findMany({
125 where: { deletedAt: null },
126 select: { key: true },
127 }),
128 prisma.fileVersion.findMany({
129 where: { file: { deletedAt: null } },
130 select: { key: true },
131 }),
132 ]);
133 const live = new Set<string>();
134 for (const f of files) live.add(f.key);
135 for (const v of versions) live.add(v.key);
136 return live;
137}
138
139export async function runOrphanSweep(opts: OrphanSweeperOptions): Promise<OrphanReport> {
140 const { s3, prisma } = opts;
141 const log = opts.log ?? consoleLogger;
142 const graceDays = opts.graceDays ?? ORPHAN_GRACE_DAYS_DEFAULT;
143 const dryRun = opts.dryRun ?? false;
144 const maxObjects = opts.maxObjects ?? 10_000;
145 const t0 = Date.now();
146
147
148 const keys: string[] = [];
149 let token: string | undefined;
150 do {
151 const page = await s3.listObjects("files/", 1000, token);
152 keys.push(...page.keys);
153 token = page.truncated ? page.nextToken : undefined;
154 if (keys.length >= maxObjects) break;
155 } while (token);
156
157
158 const live = await loadLiveLedger(prisma);
159
160
161 const now = Date.now();
162 const graceMs = graceDays * 24 * 3600 * 1000;
163 const report: OrphanReport = {
164 scannedKeys: keys.length,
165 orphansFound: 0,
166 deleted: 0,
167 retained: 0,
168 dryRun,
169 keys: [],
170 };
171 if (opts.fileId !== undefined) report.fileId = opts.fileId;
172
173 for (const key of keys.slice(0, maxObjects)) {
174 const parsed = parseOrphanKey(key);
175
176 if (opts.fileId && parsed?.fileId !== opts.fileId) continue;
177 let reason: string;
178 if (!parsed) {
179 reason = "malformed_key";
180 } else if (live.has(key)) {
181 continue;
182 } else {
183 reason = "no_ledger_row";
184 }
185
186 report.orphansFound += 1;
187 report.keys.push(key);
188
189 if (dryRun) {
190 report.retained += 1;
191 continue;
192 }
193
194
195
196 let lastModified = 0;
197 try {
198 const head = await s3.headObject(key);
199 lastModified = head?.lastModified ? new Date(head.lastModified).getTime() : 0;
200 } catch {
201
202 report.retained += 1;
203 continue;
204 }
205 if (graceMs > 0 && now - lastModified < graceMs) {
206 report.retained += 1;
207 continue;
208 }
209
210 await s3.deleteObject(key);
211 report.deleted += 1;
212 const ws = parsed?.workspaceId;
213 const fileId = parsed?.fileId;
214 log.warn(
215 { key, reason, ws, fileId, ageDays: lastModified ? Math.floor((now - lastModified) / 86_400_000) : 0 },
216 "orphan object purged"
217 );
218
219
220 try {
221 await prisma.auditLog.create({
222 data: {
223 actorType: "SYSTEM",
224 actorId: "worker",
225 workspaceId: ws ?? null,
226 resourceType: "file",
227 resourceId: fileId ?? "unknown",
228 action: "file.orphan_purged",
229 metadata: { key, reason },
230 },
231 });
232 } catch (err) {
233 log.warn({ err: (err as Error).message, key }, "orphan purge audit failed (non-fatal)");
234 }
235 }
236
237 log.info({ ...report, ms: Date.now() - t0 }, "orphan sweep done");
238 return report;
239}
240