The problem
Every photo and video on our platform lived inside a booking. That sounds reasonable until real life gets in the way.
Sometimes we shoot first and do the paperwork later: a friend of a client asks for a quick portrait session on the spot, a brand shoot is agreed over the phone, or a customer pays in person and their account only gets created days afterwards. The files are ready, the editor wants to start, and there's no booking to put them in.
So we did what you'd expect. The files went into a stand-in booking, and when the real booking existed, someone downloaded everything and uploaded it again. For a few photos that's annoying. For a shoot with raw video it means pulling several gigabytes down to a laptop and pushing them back up over the same connection, then waiting for every video to be encoded again (Part 10 covers what encoding costs us). And while a file sat in the wrong booking, it was one wrong click away from showing up in the wrong customer's gallery.
This post is about the fix: a studio media library for files that don't have a home yet, and a move that takes files from one booking to another. The interesting part is that neither of them copies a single byte.
The idea: the file doesn't move, the row does
In Part 5 we described how uploads work. The browser sends the file straight to a private storage bucket, and the database gets a row that points at it:
media_assets
id 4812
booking_id 312
customer_id 97
object_key bookings/312/7c1e…-IMG_0042.jpg
file_name IMG_0042.jpgThe object_key is the file's address in storage. Everything that decides who can see the file lives on the row: its booking, its customer, whether it's locked behind the paywall. The storage object itself knows nothing about any of that.
So moving a file between bookings doesn't need storage at all. Change booking_id and customer_id, and the file now belongs to the other booking. A 3 GB video moves as fast as a 3 KB thumbnail, because both are one UPDATE.
The obvious objection: the key still says bookings/312/… after the file has moved to booking 540. Doesn't that matter?
Only if something reads meaning into the path. We checked every place that might:
- Access checks go through the row, never the path. A signed URL is only issued after the row says this viewer may see this file.
- Deleting a booking removes its files row by row: it loads the booking's rows and deletes each row's object. It never does "delete everything under
bookings/312/". If it did, deleting the old stand-in booking would wipe a file that now belongs to someone else. - Renaming a file already worked this way. The display name changes, the key doesn't.
That second point is the one to check in your own system before trying this. If any cleanup job deletes by prefix, moving rows without moving objects will eventually delete live files. Ours didn't, so the key is just an internal address, like an inode number, and a slightly misleading folder name in a bucket nobody browses is a price we're happy to pay.
The media library
The library is a holding area with folders. Each folder has a name and a free-text note for whatever will help match it to a booking later: a phone number, a shoot date, "the café portraits on Saturday".
CREATE TABLE library_folders (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(150) NOT NULL,
note VARCHAR(1000),
created_at TIMESTAMP NOT NULL
);
CREATE UNIQUE INDEX uq_library_folders_name ON library_folders(lower(name));
CREATE TABLE library_assets (
id BIGSERIAL PRIMARY KEY,
folder_id BIGINT NOT NULL REFERENCES library_folders(id) ON DELETE CASCADE,
object_key VARCHAR(1024) NOT NULL UNIQUE,
file_name VARCHAR(255),
content_type VARCHAR(255),
file_size_bytes BIGINT,
thumbnail_object_key VARCHAR(1024),
uploaded_at TIMESTAMP NOT NULL
);Uploads into the library use the same resumable, direct-to-storage queue as deliverables, so a dropped connection halfway through a 4 GB video resumes rather than restarting. Library files go under their own library/{folderId}/… prefix.
When the booking exists, an admin selects files and picks Move to booking, with a destination: Deliverables (what the customer receives), Raw footage (what the customer can browse and pick from), or Editor source (raw files only the editing team sees).
The move creates the booking's row from the library row and deletes the library row. The object key is handed over untouched:
MediaAsset media = mediaAssetRepository.save(MediaAsset.builder()
.booking(booking)
.customer(booking.getCustomer())
.objectKey(asset.getObjectKey()) // same bytes, new owner
.thumbnailObjectKey(asset.getThumbnailObjectKey())
.fileName(name)
.locked(locked)
.build());
libraryAssetRepository.delete(asset);All of this runs in one transaction. Either the file now belongs to the booking, or it's still in the library. There's no moment where it belongs to both, or to neither.
The cleanup job had to learn about the library
Part 5 described our orphan sweeper. When the browser asks for an upload URL, we write a small "pending upload" record. Once a day, any pending upload older than 24 hours whose key isn't referenced by a real row is assumed abandoned, and its object is deleted.
A new table that holds object keys is a new place a file can be "referenced". If the sweeper doesn't know about it, every library upload looks abandoned after a day and gets deleted. So the check grew one line:
private boolean isReferenced(String objectKey) {
return mediaAssetRepository.existsByObjectKey(objectKey)
|| rawFootageAssetRepository.existsByObjectKey(objectKey)
// … proofs, attachments, shoot-day photos …
|| libraryAssetRepository.existsByObjectKey(objectKey);
}It's the easiest line in the feature to forget and the most expensive one to miss, which is why the sweeper's class comment now says, in so many words, that any new table holding object keys must be added here.
Lesson. When a background job decides what's garbage by looking for references, every new kind of reference has to be taught to it. Write that rule down next to the job, not in someone's memory.
Moving between bookings: what changes with the owner
Library files belong to nobody yet, so moving them is simple. Moving a file from one booking to another is where the real thinking went, because a file picks up history while it sits in a booking. Some of that history belongs to the file. Some of it belongs to the old customer, and must not follow the file into a stranger's gallery.
We went through every table that points at a media file and sorted it:
| Tied to the file | What happens on a move | Why |
|---|---|---|
| The stored original | Kept | It's the same photo or video |
| Thumbnail | Kept | Made from the pixels, no identity in it |
| Clean HLS stream | Kept | Same pixels, no identity, and it's the expensive part to rebuild |
| Watermarked preview stream and image | Deleted and rebuilt | The customer's id is burned into the frames (Part 6) |
| Feedback comments and read markers | Removed | A conversation with the old customer |
| Gallery folders | Removed | Part of the old booking's organisation |
| Share-link entries | Removed | The old booking's guests would keep seeing it |
| Raw footage edit selections and trimmed clips | Removed (clip files deleted too) | Choices the old customer made |
| Paywall lock | Recomputed | Depends on the new booking's payment |
The watermark row is the interesting one. Our previews for unpaid customers aren't watermarked with just a logo: they carry the customer's own id, drifting across the frame, so a leaked preview can be traced back to the account it came from. Move that file to another customer without rebuilding the preview and you'd be showing customer B a preview stamped with customer A's id. Worse, if B leaked it, it would point at A.
So the move checks whether the customer changes, and only then throws the previews away:
boolean customerChanged = media.getCustomer() == null
|| !media.getCustomer().getId().equals(newCustomer.getId());
if (customerChanged) {
hlsStreamService.deleteStream(media.getObjectKey(), Flavour.PREVIEW);
previewImageService.deletePreview(media.getObjectKey());
media.setHlsPreviewMasterKey(null);
media.setPreviewImageKey(null);
// … and the matching error / queued-at columns
}If the file is still locked in its new booking, the new customer's preview is queued right away, after the transaction commits, so it's ready before they open their gallery. The clean stream, which has no identity in it, is kept. For a video that's most of the encoding cost saved.
The old conversation goes too. It's tempting to keep comments "for history", but a comment like "can you brighten my sister's face" makes no sense, and leaks something, in another family's gallery. The audit log keeps the history instead: every move records who moved which file, from which booking, to which.
Lesson. "Move" sounds like one operation, but it's really a question asked of every table that points at the thing: does this belong to the object, or to its old owner? Make the list explicitly. The rows you forget are the ones that leak.
The name clash that would have overwritten a file
The bug we almost shipped was about file names.
On our platform, uploading a file whose name already exists in a booking replaces the old file. That's deliberate: an editor re-exports IMG_0042.jpg with a colour fix, uploads it, and the customer sees the new version in the same spot.
Now move IMG_0042.jpg into a booking that already has an IMG_0042.jpg. Cameras reuse names all the time, so this isn't rare. The move itself works: two rows, two different objects. But the booking now has two files with the same name, and the next time the editor uploads that name, the "replace" lookup picks whichever row comes back first. One of two different photos gets overwritten, and nobody can say in advance which.
The fix is to never let a move create a clash. A moved file that would collide gets a suffix, the way a desktop does when you copy a file into a folder:
static String unique(String name, Predicate<String> taken) {
if (!taken.test(name)) return name;
int dot = name.lastIndexOf('.');
String stem = dot > 0 ? name.substring(0, dot) : name;
String ext = dot > 0 ? name.substring(dot) : "";
for (int i = 2; i < 10_000; i++) {
String candidate = stem + " (" + i + ")" + ext;
if (!taken.test(candidate)) return candidate;
}
return stem + " (" + System.currentTimeMillis() + ")" + ext;
}The lookup is case-insensitive, because the replace lookup is too: img_0042.JPG and IMG_0042.jpg are the same name as far as overwriting goes. The response lists every rename (IMG_0042.jpg -> IMG_0042 (2).jpg), so the admin isn't surprised later.
Lesson. When a new feature can put data into a state the old code never had to handle, look at what the old code does in that state. Here, "two files with one name" was impossible before moves existed, and the upload code quietly relied on that.
The rules we kept small
A few decisions kept the feature from growing:
- Deliverables need a customer. A booking created from a phone inquiry may not be linked to an account yet. Deliverables are tied to a customer, so moving them there is refused with a clear message. Raw footage belongs to the booking alone, so it can go in straight away.
- A file keeps its section. Deliverables move as deliverables, raw footage as raw footage, and raw footage keeps its audience (client or editor-only). Turning a raw clip into a deliverable is a different decision, with different rules about who sees it, so it isn't a side effect of a move.
- Only files from the source booking. The request names a source booking and file ids. Any id that isn't actually in that booking is skipped and reported, so a stale browser tab can't move a file it shouldn't be able to see.
- One transaction per move. If anything fails, nothing moved.
What we'd do differently
Two gaps are still open:
- No way back to the library. If a file was moved to the wrong booking, you move it again to the right one, which works. But you can't park it back in the library while you work out where it belongs.
- A move can't change a file's section. That was deliberate (see above), but it means "these raw clips are actually the final edits" is still a download and an upload.
Takeaways
- If access is decided by a database row, moving data can be a row update. Don't copy bytes you don't have to.
- Before doing that, check that nothing cleans up storage by path or prefix. Delete row by row, and an object key becomes just an address.
- A reference-counting cleanup job must know about every table that holds references. Write the rule beside the job.
- Treat "move" as a question for every table that points at the thing: does this belong to the object, or to its owner?
- Anything that encodes identity, like a traceable watermark, has to be rebuilt when ownership changes.
- New features can create states old code assumed were impossible. Look for them before users do.
- Do the move in one transaction, and record it in the audit log. History belongs there, not in the new owner's view.