Undo on any S3 bucket, without versioning.
How Balti makes every delete, overwrite and move reversible on R2, S3, Spaces or MinIO, using nothing but a hidden prefix and an audit log that knows how to reverse itself.
Balti is a file manager for the buckets behind a CDN. A teammate drags a new logo onto brand/logo.svg, clicks publish, and a minute later every user of two AI products sees it. That speed is the point, and it is also the problem: an overwrite on a CDN bucket is instant and permanent. The old file is gone, and the first person to notice is usually a customer.
So every destructive action in Balti needed an undo, and it needed to work under two constraints that ruled out the obvious answer.
Why not object versioning
S3 object versioning is the textbook fix: turn it on and every overwrite keeps the previous version. It did not fit, for three reasons.
- Balti has to work on any S3‑compatible store. Cloudflare R2, AWS S3, DigitalOcean Spaces, Wasabi and MinIO all speak the same object API, but not every provider Balti targets offers versioning, and the ones that do configure it differently.
- Versioning is a bucket setting. Turning it on needs bucket‑level permission. Balti asks for a key that can read and write objects, and I did not want the product to depend on a key that can reconfigure the bucket itself.
- Versions only undo overwrites. They do nothing for a move, and the undo of uploading a brand‑new file is a delete, not a restore. I wanted one mechanism for every action, not versioning plus special cases.
The audit log is the undo stack
Every write in Balti already goes through a route handler, because the storage keys never leave the server. That meant every write also passes one place where it can be recorded. So the audit log became the undo stack: each entry carries not just what happened, but the exact recipe for reversing it.
export type AuditRollback =
| { type: 'restore-trash'; trashPrefix: string; keys: { trashKey: string; originalKey: string }[] }
| { type: 'move-back'; moves: { from: string; to: string }[] }
| { type: 'delete-keys'; keys: string[] }
| { type: 'none' };
Four cases cover every action. Deletes, overwrites and in‑place saves restore from trash. Moves and renames move back. A new upload is undone by deleting the keys it created. Purges and invites are recorded with none, because there is nothing to put back.
Snapshot first, then write
Before any destructive write, the route mints an audit id and copies the originals into a hidden prefix keyed by that id. Only then does the write happen, and only after the write does the entry, with its recipe, go into Postgres.
export const TRASH_PREFIX = '.cdn-desk-trash/';
export const snapshotToTrash = async (
connection: ConnectionPayload,
keys: string[],
auditId: string
) => {
const mapping = keys.map((key) => ({
originalKey: key,
trashKey: `${TRASH_PREFIX}${auditId}/${key}`,
}));
await copyObjects(
connection,
mapping.map((entry) => ({ from: entry.originalKey, to: entry.trashKey }))
);
return mapping;
};
The order is the safety property. If the snapshot fails, the request throws before anything is deleted. If the delete fails after the snapshot, the result is a harmless extra copy. Keying the trash by audit id means a snapshot can never collide with another one, and the listing route filters the prefix out, so nobody browses into it by accident.
The copy is a server‑side CopyObject, so the bytes never pass through the serverless function. A 400 MB video snapshots as fast as a 4 KB JSON file.
Rolling back
The rollback route is a switch over the recipe. It is admin‑only, it refuses an entry that was already rolled back, and it refuses when the connection the change was made on no longer exists.
if (entry.rollback.type === 'restore-trash') {
await copyObjects(
payload,
entry.rollback.keys.map((pair) => ({ from: pair.trashKey, to: pair.originalKey }))
);
await deleteKeys(payload, entry.rollback.keys.map((pair) => pair.trashKey));
} else if (entry.rollback.type === 'move-back') {
await moveObjects(payload, entry.rollback.moves);
} else if (entry.rollback.type === 'delete-keys') {
await deleteKeys(payload, entry.rollback.keys);
}
The rollback is itself logged, with a recipe of none. That was deliberate. An undo of an undo sounds useful until two admins start ping‑ponging a file, and a linear history where every reversal is visible is easier to reason about than a tree.
What it costs, and the gaps
Every destructive write costs one extra copy and the storage that copy occupies. For a CDN bucket full of images and config files that is cheap, and it was the trade I wanted to make.
There are two honest gaps. The trash prefix grows forever today; the next step is a lifecycle rule that expires it after a retention window, which every provider on the list supports. And if the log write fails after a delete, the snapshot exists but no entry points at it, so the undo button is missing for that one change. The copy is still in the bucket under its audit id, so it can be recovered by hand, but it is a manual step.
Both are cheap to close, and neither changes the shape of the design. The part that matters held: anyone on the team can make a change on a production CDN, and an admin can take it back with one click, on whichever provider the bucket lives.