Skip to content
Chapter 68Lesson 6

Quiz - Object storage

Quiz progress

0 / 0

A teammate wants to add R2 to a brand-new B2B SaaS because “every file should live in object storage, it keeps the database clean.” Three of the app’s payloads are below. Which one actually crosses the threshold that puts a bucket on the table?

A 2 KB JSON blob of saved user preferences, tied to one row.
Contract PDFs that members upload and their teams download repeatedly.
The handful of hero and icon images on the marketing pages.

A read-heavy SaaS serves 50 TB of user files back to browsers every month. Why does an experienced engineer reach for R2 over S3 here?

The bill for this product is dominated by bytes leaving the store on every download, and egress is the one line item R2 doesn’t meter while S3 does.
R2 is open source while S3 is proprietary, so R2 avoids the per-GB licensing fees baked into S3.
R2’s S3-compatible API is faster on the wire, so each of the 50 TB of downloads finishes sooner.

You’re scoping the R2 token your Next.js app will authenticate with in production. Which grant is the senior default?

One token per environment, scoped to that environment’s single bucket, with Object Read & Write.
One Admin Read & Write token shared across staging and production, so you only manage one secret.
One account-level Object Read & Write token, so the same code reaches whichever bucket the environment points at.

Your presigned upload works on a colleague’s machine but fails for a new contractor with a CORS error in the browser — the PUT is cancelled before it’s even sent, and the signature is definitely valid. Where is the fix?

On the bucket’s CORS rule — it must list the origin, method, and content-type header the browser’s preflight asks for.
In the signing code — the presigned URL must include the requesting origin in its signed parameters.
In the token scope — the R2 token needs the contractor’s origin added to its allowed-origins list.

You sign a presigned PUT with ContentLength set to the client’s claimed size, and a content-type allow-list check before signing. A malicious client streams a 2 GB body through the valid URL anyway. Which check is the one that actually stops the oversized file from being accepted?

A post-upload HeadObjectCommand that reads the real stored size from R2 and refuses to write the metadata row if it exceeds the cap.
The signed ContentLength — R2 rejects any PUT whose body exceeds the value baked into the signature.
The content-type allow-list — once the type is approved before signing, R2 caps the body at the type’s expected size.

An export feature emails users a download link. A teammate sets the presigned GET’s expiry to 24 hours “so the link works all day.” What’s the senior objection?

A long-lived signed URL is a leak surface — it sits in the email provider’s logs, the inbox, and every forward, downloadable by anyone for a full day; mail a link to an app route that mints a fresh short-lived GET on click instead.
24 hours is below R2’s minimum GET expiry, so R2 will silently clamp it and the link will be dead within minutes.
The expiry should instead be stored in the file_metadata.url column so the link can be reused without re-signing.

In the safe direct-to-R2 upload flow, why is the file_metadata row inserted after the byte transfer completes rather than before?

Writing the row last biases failures toward orphan bytes (cheap litter a sweep reclaims) instead of orphan rows (a correctness bug where the UI lists a file that 404s).
The row can’t be built until the upload returns the object key, which the server only learns once R2 confirms the PUT.
Inserting before the upload would hold a database transaction open across the slow byte transfer.

You’re choosing the uniqueness constraint for the objectKey column on file_metadata, which uses soft delete. Which is correct?

A plain global .unique() with no where — the key stays unique even after the row is soft-deleted.
A partial unique where soft_deleted_at is null — only live rows compete for uniqueness, matching the slug pattern from earlier work.

A user in org A pastes a fileId that really belongs to org B and hits the download route, which calls getFile('A', thatId) through tenantDb('A'). What comes back?

null — the org B row was never in the candidate set, so the lookup finds nothing and the route returns an ordinary 404.
The org B row, so the route must then compare row.organizationId against 'A' before trusting it.

The chapter drilled “the function is never a byte pipe,” yet the CSV export’s Trigger.dev worker streams the whole file through itself with a server-side PutObjectCommand. Why isn’t that a violation?

The rule protects the synchronous request path — a user waiting, a timeout, a doubled per-request bandwidth bill. A background worker has none of those, and the bytes are already in memory, so presigning a PUT back to itself would be pure ceremony.
Server-side PUTs from a worker are exempt because the worker authenticates with the R2 token directly rather than a presigned URL.
The export is small enough to stay under the function timeout, so routing its bytes through the worker is acceptable where a large upload wouldn’t be.

User uploads get a file_metadata row; the CSV export output deliberately doesn’t. Which property of the export is the reason it skips the row?

It’s short-lived and single-consumer — one recipient clicking one emailed link inside a 10-minute window, never listed, owned, or managed, so a row would be write-only noise a lifecycle rule replaces.
It’s generated server-side rather than uploaded by a browser, and only browser PUTs are recorded in file_metadata.
It’s a CSV rather than a binary file, and file_metadata only tracks binary payloads.

Quiz complete

Score by topic