diff --git a/.gitignore b/.gitignore index 16b6dfe..884c098 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,7 @@ npm-debug.log* # Playwright MCP debug artifacts .playwright-mcp/ .dedupe-report.json +.orphan-report.json # Playwright e2e .next-e2e diff --git a/lib/photo-upload.test.ts b/lib/photo-upload.test.ts index d8e93a1..f898f90 100644 --- a/lib/photo-upload.test.ts +++ b/lib/photo-upload.test.ts @@ -43,16 +43,20 @@ function existingPhoto(contentHash: string): Photo { }; } -function fakeSupabase(options: { failUploadFor?: string[]; clashHashes?: string[]; insertErrorMessage?: string } = {}) { +function fakeSupabase(options: { failUploadFor?: string[]; existingUploadFor?: string[]; clashHashes?: string[]; legacyClashHashes?: string[]; lateClashHashes?: string[]; clashError?: string; recheckError?: string; insertErrorMessage?: string; insertThrows?: string } = {}) { const uploaded: string[] = []; + let clashQueries = 0; + const upserted: boolean[] = []; const removed: string[] = []; const inserted: Array> = []; const client = { storage: { from: () => ({ - upload: async (path: string, file: File) => { + upload: async (path: string, file: File, uploadOptions?: { upsert?: boolean }) => { if (options.failUploadFor?.some((name) => file.name.startsWith(name))) return { error: { message: "storage exploded" } }; + if (options.existingUploadFor?.some((name) => file.name.startsWith(name))) return { error: { message: "The resource already exists", statusCode: "409" } }; uploaded.push(path); + upserted.push(Boolean(uploadOptions?.upsert)); return { error: null }; }, remove: async (paths: string[]) => { @@ -64,11 +68,31 @@ function fakeSupabase(options: { failUploadFor?: string[]; clashHashes?: string[ from: () => ({ select: () => ({ eq: () => ({ - in: async () => ({ data: (options.clashHashes ?? []).map((hash) => ({ content_hash: hash })) }), + in: async () => { + clashQueries += 1; + const errorMessage = clashQueries === 1 ? options.clashError : options.recheckError; + if (errorMessage) return { data: null, error: { message: errorMessage } }; + const hashes = [ + ...(options.clashHashes ?? []), + ...(options.legacyClashHashes ?? []), + // Rows a racer inserted between the clash check and the insert: + // visible only from the second (post-failure) query onward. + ...(clashQueries > 1 ? options.lateClashHashes ?? [] : []), + ]; + return { + data: hashes.map((hash) => ({ + content_hash: hash, + image_path: options.legacyClashHashes?.includes(hash) ? `lofoten-2026/legacy-${hash}.mp4` : `lofoten-2026/${hash}.mp4`, + thumbnail_path: null, + })), + error: null, + }; + }, }), }), insert: (rows: Array>) => ({ select: async () => { + if (options.insertThrows) throw new Error(options.insertThrows); if (options.insertErrorMessage) return { data: null, error: { message: options.insertErrorMessage } }; inserted.push(...rows); const returned = rows.map((row, index) => ({ ...row, id: `row-${index}` })); @@ -77,7 +101,7 @@ function fakeSupabase(options: { failUploadFor?: string[]; clashHashes?: string[ }), }), } as unknown as SupabaseClient; - return { client, uploaded, removed, inserted }; + return { client, uploaded, upserted, removed, inserted }; } const trip = { id: "trip-1", slug: "lofoten-2026" }; @@ -137,16 +161,100 @@ describe("uploadPhotoBatch", () => { expect(inserted).toHaveLength(1); }); - it("removes storage objects for hashes someone else uploaded mid-batch", async () => { + // New rows are content-addressed, so a racing upload of an already-stored hash + // lands on the live row's exact key. Removing it would strand that row. + it("leaves storage alone for hashes someone else uploaded mid-batch", async () => { const { client, removed, inserted } = fakeSupabase({ clashHashes: ["hash-a"] }); const { result } = await batch(client, [input({ clientId: "a" }), input({ clientId: "b" })]); expect(result.failedClientIds).toEqual(["a"]); expect(result.savedClientIds).toEqual(["b"]); - expect(removed).toHaveLength(1); + expect(removed).toEqual([]); expect(inserted).toHaveLength(1); }); + it("removes an unused hash-path upload when the clashing row has a legacy UUID path", async () => { + const { client, removed, inserted } = fakeSupabase({ legacyClashHashes: ["hash-a"] }); + const { result } = await batch(client, [input({ clientId: "a" }), input({ clientId: "b" })]); + + expect(result.failedClientIds).toEqual(["a"]); + expect(result.savedClientIds).toEqual(["b"]); + expect(removed).toEqual(["lofoten-2026/hash-a.mp4"]); + expect(inserted).toHaveLength(1); + }); + + it("does not clean up live objects when the clash check fails", async () => { + const { client, removed } = fakeSupabase({ clashError: "clash lookup failed" }); + const { result } = await batch(client, [input({ clientId: "a" })]); + + expect(result.insertErrorMessage).toBe("clash lookup failed"); + expect(result.failedClientIds).toEqual(["a"]); + expect(result.inserted).toBe(false); + expect(removed).toEqual([]); + }); + + it("keys objects by content hash without overwriting immutable objects", async () => { + const first = fakeSupabase(); + await batch(first.client, [input({ clientId: "a" })]); + // Same file, second attempt: a batch that died before its insert last time. + const second = fakeSupabase(); + await batch(second.client, [input({ clientId: "a" })]); + + expect(first.uploaded).toEqual(["lofoten-2026/hash-a.mp4"]); + expect(second.uploaded).toEqual(first.uploaded); + expect(first.upserted).toEqual([false]); + }); + + it("reuses an immutable object left by an earlier unknown-outcome attempt", async () => { + const { client, inserted } = fakeSupabase({ existingUploadFor: ["a.jpg"] }); + const { result } = await batch(client, [input({ clientId: "a" })]); + + expect(result.savedClientIds).toEqual(["a"]); + expect(inserted).toHaveLength(1); + }); + + it("rolls back only the fresh objects when the insert fails", async () => { + const { client, removed } = fakeSupabase({ clashHashes: ["hash-a"], insertErrorMessage: "insert exploded" }); + const { result } = await batch(client, [input({ clientId: "a" }), input({ clientId: "b" })]); + + expect(result.insertErrorMessage).toBe("insert exploded"); + // b's object is unreferenced and goes; a's is the existing row's own object. + expect(removed).toEqual(["lofoten-2026/hash-b.mp4"]); + expect(result.savedClientIds).toEqual([]); + }); + + it("spares objects a racing insert claimed when the unique index rejects the batch", async () => { + const { client, removed } = fakeSupabase({ lateClashHashes: ["hash-a"], insertErrorMessage: "unique constraint" }); + const { result } = await batch(client, [input({ clientId: "a" }), input({ clientId: "b" })]); + + expect(result.insertErrorMessage).toBe("unique constraint"); + // hash-a gained a row mid-window, and that row references our own + // content-addressed objects; only hash-b's object is truly unreferenced. + expect(removed).toEqual(["lofoten-2026/hash-b.mp4"]); + }); + + it("skips rollback cleanup when the post-failure re-check fails", async () => { + const { client, removed } = fakeSupabase({ insertErrorMessage: "insert exploded", recheckError: "recheck down" }); + const { result } = await batch(client, [input({ clientId: "a" })]); + + expect(result.insertErrorMessage).toBe("insert exploded"); + expect(removed).toEqual([]); + expect(result.warnings.some((warning) => warning.includes("recheck down"))).toBe(true); + }); + + it("leaves uploads in place when the insert throws", async () => { + const { client, removed, uploaded } = fakeSupabase({ insertThrows: "network died" }); + const { result } = await batch(client, [input({ clientId: "a" })]); + + expect(result.insertErrorMessage).toBe("network died"); + expect(result.failedClientIds).toEqual(["a"]); + expect(result.inserted).toBe(false); + // Which hashes gained a row is unknown here, so blind cleanup could delete a + // live object. The content-addressed key is reused by the retry instead. + expect(uploaded).toEqual(["lofoten-2026/hash-a.mp4"]); + expect(removed).toEqual([]); + }); + it("rolls back every uploaded object when the insert fails", async () => { const { client, removed, inserted } = fakeSupabase({ insertErrorMessage: "unique constraint" }); const { result } = await batch(client, [input({ clientId: "a" }), input({ clientId: "b" })]); diff --git a/lib/photo-upload.ts b/lib/photo-upload.ts index a0d71dd..6058cbb 100644 --- a/lib/photo-upload.ts +++ b/lib/photo-upload.ts @@ -59,8 +59,9 @@ type PendingRow = { * thumbnail concurrently per item (bounded by `concurrency`), re-check * content hashes against the database (someone else may have uploaded the * same photo mid-batch), then insert the surviving rows. Storage objects are - * removed again whenever their row cannot be inserted, so a failure never - * leaves orphaned files behind. + * removed again when it is safe to prove that their row was not inserted. + * If the database outcome is unknown, content-addressed paths let a retry + * reuse the same objects instead of creating another abandoned copy. * * Pure orchestration over the injected client — UI state (error banners, * reloads, panel close) stays with the caller, driven by the outcome. @@ -78,13 +79,32 @@ export async function uploadPhotoBatch(options: { const rows: PendingRow[] = []; const failures: string[] = []; const warnings: string[] = []; - const uploadedPaths: string[] = []; const savedClientIds: string[] = []; const failedClientIds: string[] = []; let insertErrorMessage: string | null = null; let inserted = false; let insertedRows: Photo[] = []; + const storagePathsOf = (pending: PendingRow[]) => + pending.flatMap((row) => [row.image_path, row.thumbnail_path].filter((path): path is string => Boolean(path))); + + // A cleanup that silently fails is how the bucket filled up with unreachable + // objects in the first place, so a failed removal is reported, not dropped. + const removeObjects = async (paths: string[], label: string) => { + if (paths.length === 0) return; + const { error } = await supabase.storage.from(PHOTO_BUCKET).remove(paths); + if (error) warnings.push(`${label}: storage cleanup failed (${error.message})`); + }; + + // Hash paths are immutable. A retry may find the object left by an earlier + // attempt whose database outcome was unknown; that is success, not a reason + // to overwrite bytes already cached under this key. + const isAlreadyStored = (error: unknown) => { + if (!error || typeof error !== "object") return false; + const value = error as { statusCode?: string | number; status?: string | number; message?: string }; + return Number(value.statusCode ?? value.status) === 409 || /already exists|duplicate/i.test(value.message ?? ""); + }; + const { uploads: uploadCandidates, duplicates } = partitionDuplicatePhotos( inputs.map((input) => ({ input, contentHash: input.contentHash, mediaType: input.mediaType, takenAt: input.exif?.takenAt ?? null, coordinate: input.coordinate })), existingPhotos, @@ -98,8 +118,12 @@ export async function uploadPhotoBatch(options: { await mapWithConcurrency(uploadCandidates.map((candidate) => candidate.input), concurrency, async (input) => { const prepared = await prepareMediaFiles(input.file); const extension = storageFileExtension(prepared.imageFile); - const path = `${trip.slug}/${crypto.randomUUID()}.${extension}`; - const thumbnailPath = prepared.thumbnailFile ? `${trip.slug}/thumbs/${crypto.randomUUID()}.jpg` : null; + // Content-addressed, so an upload is idempotent: a retry of a batch that + // died before its insert lands on the same immutable key and reuses the + // existing object instead of stranding a fresh uuid. `(trip_id, content_hash)` is + // already unique in the database, so one key here is one row there. + const path = `${trip.slug}/${input.contentHash}.${extension}`; + const thumbnailPath = prepared.thumbnailFile ? `${trip.slug}/thumbs/${input.contentHash}.jpg` : null; // The thumbnail never depends on the image upload, so both go up // together instead of back to back. const [imageUpload, thumbnailUpload] = await Promise.all([ @@ -108,20 +132,20 @@ export async function uploadPhotoBatch(options: { ? supabase.storage.from(PHOTO_BUCKET).upload(thumbnailPath, prepared.thumbnailFile, { cacheControl: IMMUTABLE_CACHE_SECONDS, upsert: false, contentType: prepared.thumbnailFile.type }) : Promise.resolve(null), ]); - if (imageUpload.error) { - if (thumbnailPath && thumbnailUpload && !thumbnailUpload.error) await supabase.storage.from(PHOTO_BUCKET).remove([thumbnailPath]); + if (imageUpload.error && !isAlreadyStored(imageUpload.error)) { + if (thumbnailPath && thumbnailUpload && !thumbnailUpload.error) { + warnings.push(`${input.file.name}: thumbnail retained for a safe retry`); + } failures.push(`${input.file.name}: ${imageUpload.error.message}`); failedClientIds.push(input.clientId); onItemComplete(); return; } - uploadedPaths.push(path); let thumbnailStoragePath: string | null = null; if (thumbnailUpload) { - if (thumbnailUpload.error) { + if (thumbnailUpload.error && !isAlreadyStored(thumbnailUpload.error)) { warnings.push(`${input.file.name}: thumbnail skipped`); } else if (thumbnailPath) { - uploadedPaths.push(thumbnailPath); thumbnailStoragePath = thumbnailPath; } } @@ -144,43 +168,93 @@ export async function uploadPhotoBatch(options: { }); if (rows.length > 0) { - // Re-check hashes against the database rather than local state, so a - // photo someone else uploaded mid-batch is skipped instead of failing - // the whole insert on the unique index. - const { data: clashData } = await supabase.from("photos").select("content_hash").eq("trip_id", trip.id).in("content_hash", rows.map((row) => row.content_hash)); - const clashes = new Set(((clashData ?? []) as Array<{ content_hash: string }>).map((row) => row.content_hash)); - const clashedRows = rows.filter((row) => clashes.has(row.content_hash)); - const freshRows = rows.filter((row) => !clashes.has(row.content_hash)); - if (clashedRows.length > 0) { - await supabase.storage.from(PHOTO_BUCKET).remove(clashedRows.flatMap((row) => [row.image_path, row.thumbnail_path].filter((rowPath): rowPath is string => Boolean(rowPath)))); - failures.push(`${clashedRows.length} media item${clashedRows.length === 1 ? "" : "s"} already uploaded by someone else, skipped`); - failedClientIds.push(...clashedRows.map((row) => row.client_id)); - } - const insertRows = freshRows.map((row) => ({ - trip_id: row.trip_id, - day_id: row.day_id, - uploader_name: row.uploader_name, - content_hash: row.content_hash, - media_type: row.media_type, - image_path: row.image_path, - thumbnail_path: row.thumbnail_path, - lat: row.lat, - lng: row.lng, - taken_at: row.taken_at, - caption: row.caption, - exif_found: row.exif_found, - })); - if (insertRows.length > 0) { - const { data: returnedRows, error: insertError } = await supabase.from("photos").insert(insertRows).select(); - if (insertError) { - if (uploadedPaths.length > 0) await supabase.storage.from(PHOTO_BUCKET).remove(uploadedPaths); - insertErrorMessage = insertError.message; - failedClientIds.push(...freshRows.map((row) => row.client_id)); - } else { - savedClientIds.push(...freshRows.map((row) => row.client_id)); - inserted = true; - insertedRows = (returnedRows ?? []) as Photo[]; + try { + // Re-check hashes against the database rather than local state, so a + // photo someone else uploaded mid-batch is skipped instead of failing + // the whole insert on the unique index. + const { data: clashData, error: clashError } = await supabase + .from("photos") + .select("content_hash,image_path,thumbnail_path") + .eq("trip_id", trip.id) + .in("content_hash", rows.map((row) => row.content_hash)); + if (clashError) throw clashError; + const clashes = new Map( + ((clashData ?? []) as Array<{ content_hash: string; image_path: string; thumbnail_path: string | null }>).map((row) => [row.content_hash, row]), + ); + const clashedRows = rows.filter((row) => clashes.has(row.content_hash)); + const freshRows = rows.filter((row) => !clashes.has(row.content_hash)); + if (clashedRows.length > 0) { + // New rows already use hash paths, so a racing upload normally lands on + // the live row's own objects and must not remove them. Legacy rows still + // use UUID paths, though; in that case the hash-path uploads are unused + // and can be removed without touching the legacy row's objects. + const unusedClashPaths = clashedRows.flatMap((row) => { + const existing = clashes.get(row.content_hash)!; + return [row.image_path, row.thumbnail_path].filter( + (path): path is string => Boolean(path) && path !== existing.image_path && path !== existing.thumbnail_path, + ); + }); + await removeObjects(unusedClashPaths, "duplicate cleanup"); + failures.push(`${clashedRows.length} media item${clashedRows.length === 1 ? "" : "s"} already uploaded by someone else, skipped`); + failedClientIds.push(...clashedRows.map((row) => row.client_id)); + } + const insertRows = freshRows.map((row) => ({ + trip_id: row.trip_id, + day_id: row.day_id, + uploader_name: row.uploader_name, + content_hash: row.content_hash, + media_type: row.media_type, + image_path: row.image_path, + thumbnail_path: row.thumbnail_path, + lat: row.lat, + lng: row.lng, + taken_at: row.taken_at, + caption: row.caption, + exif_found: row.exif_found, + })); + if (insertRows.length > 0) { + const { data: returnedRows, error: insertError } = await supabase.from("photos").insert(insertRows).select(); + if (insertError) { + // The likely insert error is the unique index: a racer inserted one + // of these hashes after the clash check above, and that row now + // references our content-addressed paths. Re-check which hashes + // gained rows and remove only the paths nothing references. + const { data: recheckData, error: recheckError } = await supabase + .from("photos") + .select("content_hash,image_path,thumbnail_path") + .eq("trip_id", trip.id) + .in("content_hash", freshRows.map((row) => row.content_hash)); + if (recheckError) { + warnings.push(`rollback: skipped storage cleanup, could not confirm the objects are unreferenced (${recheckError.message})`); + } else { + const referencedPaths = new Set( + ((recheckData ?? []) as Array<{ image_path: string; thumbnail_path: string | null }>).flatMap((row) => + [row.image_path, row.thumbnail_path].filter((path): path is string => Boolean(path)), + ), + ); + await removeObjects(storagePathsOf(freshRows).filter((path) => !referencedPaths.has(path)), "rollback"); + } + insertErrorMessage = insertError.message; + failedClientIds.push(...freshRows.map((row) => row.client_id)); + } else { + savedClientIds.push(...freshRows.map((row) => row.client_id)); + inserted = true; + insertedRows = (returnedRows ?? []) as Photo[]; + } } + } catch (error) { + // The clash check or the insert threw, so which hashes have rows is now + // unknown and blind cleanup could delete a live object. The uploads are + // content-addressed, so they sit on the keys a retry of this same batch + // will overwrite -- abandoning them costs one object each, not one per + // attempt. scripts/purge-orphan-objects.mjs sweeps any never retried. + insertErrorMessage = error instanceof Error + ? error.message + : typeof error === "object" && error !== null && "message" in error && typeof error.message === "string" + ? error.message + : "Could not save uploaded media."; + const alreadyFailed = new Set(failedClientIds); + failedClientIds.push(...rows.map((row) => row.client_id).filter((clientId) => !alreadyFailed.has(clientId))); } } diff --git a/scripts/purge-orphan-objects.mjs b/scripts/purge-orphan-objects.mjs new file mode 100644 index 0000000..0d34f59 --- /dev/null +++ b/scripts/purge-orphan-objects.mjs @@ -0,0 +1,151 @@ +// Delete storage objects in the photo bucket that no photos row references. +// +// Legacy failed batches can leave UUID-path objects behind. Content-addressed +// uploads can also leave one stable object behind when a database outcome is +// unknown. Those objects are unreachable from the app but still count against +// the storage quota. +// +// An object is an orphan only if its path appears in neither image_path nor +// thumbnail_path of any row. The live set is never touched, and objects younger +// than MIN_AGE_HOURS are always spared -- an upload whose row insert has not +// landed yet is indistinguishable from an orphan, so age is what separates them. +// +// Usage: +// node scripts/purge-orphan-objects.mjs # read-only: list, diff, report +// Deletion is deliberately not automated here: a reference can be created +// between any client-side confirmation and delete. Safe deletion needs a +// server-side maintenance protocol shared with uploads. The manifest is an +// inventory for inspection until that protocol exists. + +import { readFileSync, writeFileSync } from "node:fs"; + +const REPORT_PATH = new URL("../.orphan-report.json", import.meta.url).pathname; +const PHOTO_BUCKET = "trip-photos"; +// An in-flight upload looks exactly like an orphan until its row is inserted. +const MIN_AGE_HOURS = 1; + +function readEnv(name) { + if (process.env[name]) return process.env[name]; + for (const file of [".env.local", ".env"]) { + try { + const line = readFileSync(new URL(`../${file}`, import.meta.url), "utf8") + .split("\n") + .find((entry) => entry.startsWith(`${name}=`)); + if (line) return line.slice(name.length + 1).trim(); + } catch { + // missing env file -- keep looking + } + } + return null; +} + +const SUPABASE_URL = readEnv("NEXT_PUBLIC_SUPABASE_URL"); +const ANON_KEY = readEnv("NEXT_PUBLIC_SUPABASE_ANON_KEY"); +const apply = process.argv.includes("--apply"); + +if (!SUPABASE_URL || !ANON_KEY) { + console.error("Missing NEXT_PUBLIC_SUPABASE_URL / NEXT_PUBLIC_SUPABASE_ANON_KEY."); + process.exit(1); +} +if (apply) { + console.error("--apply is disabled: deletion is unsafe without a lock shared with uploads."); + process.exit(1); +} + +const restHeaders = (key) => ({ apikey: key, Authorization: `Bearer ${key}`, "Content-Type": "application/json" }); + +async function rest(path, { method = "GET", key = ANON_KEY, body } = {}) { + const response = await fetch(`${SUPABASE_URL}${path}`, { + method, + headers: { ...restHeaders(key), ...(method === "DELETE" ? { Prefer: "return=minimal" } : {}) }, + body: body ? JSON.stringify(body) : undefined, + }); + if (!response.ok) throw new Error(`${method} ${path} -> ${response.status}: ${await response.text()}`); + return response.status === 204 ? null : response.json(); +} + +/** Every path any row points at, across both columns. */ +async function fetchReferencedPaths() { + const referenced = new Set(); + for (let from = 0; ; from += 1000) { + const page = await rest(`/rest/v1/photos?select=image_path,thumbnail_path&order=id.asc&limit=1000&offset=${from}`); + for (const row of page) { + if (row.image_path) referenced.add(row.image_path); + if (row.thumbnail_path) referenced.add(row.thumbnail_path); + } + if (page.length < 1000) break; + } + return referenced; +} + +// The list API returns one level at a time: entries with metadata are objects, +// entries without are folders to descend into. +async function listObjects(prefix = "") { + const objects = []; + for (let from = 0; ; from += 1000) { + const page = await rest(`/storage/v1/object/list/${PHOTO_BUCKET}`, { + method: "POST", + body: { prefix, limit: 1000, offset: from }, + }); + for (const entry of page) { + const path = prefix ? `${prefix}/${entry.name}` : entry.name; + if (entry.metadata?.size === undefined || entry.metadata === null) objects.push(...await listObjects(path)); + // A retried upload upserts onto its old key, which refreshes updated_at + // but not created_at -- judge age by the newest write so an object that + // was just re-uploaded (insert still pending) counts as new. + else objects.push({ path, size: entry.metadata.size, lastWriteAt: entry.updated_at ?? entry.created_at ?? null }); + } + if (page.length < 1000) break; + } + return objects; +} + +function partitionOrphans(objects, referenced, now) { + const cutoff = now - MIN_AGE_HOURS * 3600 * 1000; + const live = []; + const orphans = []; + const tooNew = []; + for (const object of objects) { + if (referenced.has(object.path)) live.push(object); + else { + const lastWriteAt = object.lastWriteAt ? Date.parse(object.lastWriteAt) : Number.NaN; + if (!Number.isFinite(lastWriteAt) || lastWriteAt > cutoff) tooNew.push(object); + else orphans.push(object); + } + } + return { live, orphans, tooNew }; +} + +const mb = (bytes) => (bytes / 1024 / 1024).toFixed(1); +const sum = (objects) => objects.reduce((total, object) => total + object.size, 0); + +const referenced = await fetchReferencedPaths(); +console.log(`Rows reference ${referenced.size} distinct paths. Listing bucket...`); + +const objects = await listObjects(); +const { live, orphans, tooNew } = partitionOrphans(objects, referenced, Date.now()); +const missing = [...referenced].filter((path) => !objects.some((object) => object.path === path)); + +writeFileSync(REPORT_PATH, JSON.stringify({ + totals: { + referencedPaths: referenced.size, + objects: objects.length, + live: live.length, + liveBytes: sum(live), + orphans: orphans.length, + orphanBytes: sum(orphans), + sparedAsTooNew: tooNew.length, + referencedButMissing: missing.length, + }, + orphans: orphans.map((object) => object.path), + missing, +}, null, 2)); + +console.log(`\n live (referenced) ${String(live.length).padStart(5)} objects ${mb(sum(live)).padStart(8)} MB`); +console.log(` orphans ${String(orphans.length).padStart(5)} objects ${mb(sum(orphans)).padStart(8)} MB <- reclaimable`); +if (tooNew.length > 0) console.log(` spared (< ${MIN_AGE_HOURS}h old) ${String(tooNew.length).padStart(5)} objects ${mb(sum(tooNew)).padStart(8)} MB`); +if (missing.length > 0) console.log(`\n WARNING: ${missing.length} referenced paths have no object -- broken rows, not touched.`); +console.log(`\n bucket after purge: ${(sum(live) / 1024 / 1024 / 1024).toFixed(2)} GB`); +console.log(`\nManifest: ${REPORT_PATH}`); + +console.log("\nInventory complete. Automatic deletion is disabled until uploads and cleanup share a server-side lock.");