7102687ee8
* fix(events): delete stored objects when cascading an event delete Stable twin of #1051. Deleting an event removed its database rows but left every stored file behind: deleteEventCascade() cleaned up with fs.rm over {STORAGE_PATH}/events/{active,archived}/{slug}, and on an S3-compatible backend those paths don't exist locally — the call succeeds against nothing and the real objects stay in the bucket, unreferenced by any row, invisible in the UI, and billed every month. Measured on a v3.45.16 install against Cloudflare R2, deleting one 403-photo event: bucket object count 5,400 before and 5,400 after, while referenced rows dropped from 3,425 to 2,746. Keys are collected BEFORE the transaction removes the photo rows — once they are gone nothing records which objects belonged to the event, and only a full-bucket audit against the whole database could find them again — and deleted AFTER the commit, so a rolled-back delete can never destroy files for an event that still exists. Includes photo.watermark_path and event.archive_path, both storage-backed and both previously fs.unlink-only. event.hero_logo_path is deliberately excluded: multer writes logos to local disk with diskStorage regardless of backend, so they are never bucket objects. Reference/external photos are left alone — resolvePhotoStorageKey returns null for them and PicPeak does not own those bytes. This branch carries the higher priority of the pair: unlike main, stable's deleteEventCascade never calls getStorage() at all, and the leak costs real money for every month it goes unfixed. Co-authored-by: Peifu Mo <peipeimo@users.noreply.github.com> * fix(events): sweep the Download All cache, and delete objects concurrently Both from an external review round on #1051. The pre-built "Download All" zip (events.download_zip_path) lives under events/active/{slug}/.download-cache/. On local disk the recursive fs.rm already covered it, which is exactly why it was easy to miss — on S3 that prefix is not a directory, nothing covered it, and it is gallery-sized. downloadZipService exposes a cleanup() documented as "used on event deletion" that the cascade never called. download_jobs (main's #173) does not exist on this branch, so the per-job archives main also sweeps have no counterpart here. Deletes now run through a bounded pool instead of one await per key: a 400-photo gallery owns well over a thousand objects, and that many sequential DeleteObject round trips runs to minutes — long enough for a proxy to time the request out AFTER the commit, leaving the event deleted and the sweep half-finished. * fix(events): never delete a derivative another gallery still uses Round-2 findings from the external reviewer on #1051, ported. Canonical thumbnail/hero/preview keys are not event-scoped: the basename is the photo's filename, and filenames are not unique across events. A legacy gallery can share a canonical derivative with a photo in another event, and deleting it here blanked a surviving gallery's tile. Derived keys are now checked against photos outside this event and anything still referenced is left alone; if the check itself fails, every derivative is kept — an orphan costs storage, a deleted derivative costs someone else's gallery. Originals need no check, their keys embed the slug. Also cancel any in-flight or debounced Download All build before snapshotting paths, via downloadZipService.cleanup() — the service's own entry point for event deletion. A builder that started before the delete would otherwise upload a gallery-sized zip after the sweep and write its path onto a row that no longer exists. * revert(events): drop the Download All build cancellation It broke CI on this branch: the backend job went from ~2 minutes to exceeding its 10-minute budget, twice, reproducibly. downloadZipService.cleanup() reaches getStorage() through _cleanup(), and in a suite where the S3 backend is configured but unreachable every cascade delete then pays the adapter's retry backoff. The full suite passes locally against SQLite, which is why this only showed up in CI. The race it addressed is real but narrow — a builder that started before the delete uploads its zip after the sweep and writes the path onto a row that no longer exists, orphaning one object. That is a cheaper problem than an unrunnable test suite, so it goes back to being a documented follow-up rather than shipping behind a timeout. The shared-derivative guard from the same review round stays: that one prevented deleting a surviving gallery's thumbnail. --------- Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local> Co-authored-by: Peifu Mo <peipeimo@users.noreply.github.com>