Last updated:
@lunora/storage wraps Cloudflare R2 in a typed
surface and threads it onto every function context as ctx.storage. A
file is addressed by a string key you choose; the bucket and signing secret
are configured once and never appear in a handler. As with everything else in
Lunora, which operations you can reach depends on the kind of function you're
in.
import { action, v } from "@/lunora/_generated/server";
export const importLogo = action.input({ key: v.string() }).action(async ({ ctx, args: { key } }) => {
const upstream = await ctx.fetch("https://example.com/logo.png");
await ctx.storage.store(key, await upstream.arrayBuffer(), { contentType: "image/png" });
return ctx.storage.getUrl(key);
});Which contexts expose what
ctx.storage is typed read-only inside a query or a mutation, and with
the full read/write surface inside an action. The split is deliberate:
queries are pure reads, and mutations run inside a transactional scope. Neither
should perform a side-effectful R2 write or delete, because those can't
participate in the transaction or be rolled back. Both can still read existing
objects and mint signed URLs, since signing is an HMAC computation with no R2
round-trip.
The split is enforced by TypeScript, not by the runtime: one object is built
per context and the type is what narrows it, so a mutation that casts past its
type still reaches delete. Where you need the narrowing to hold against more
than a compiler, add storageRules(...) — it rebuilds
ctx.storage as an allowlist of the gated surface and drops every ungated
sibling, so nothing can route around a rule.
| Operation | Query | Mutation | Action |
|---|---|---|---|
download / getMetadata / head | ✓ | ✓ | ✓ |
getUrl / getSignedUrl | ✓ | ✓ | ✓ |
deleteAfterCommit | — | ✓ | — |
generateUploadUrl | — | — | ✓ |
store | — | — | ✓ |
delete | — | — | ✓ |
In short: serve and inspect files from anywhere; mint an upload URL or write and delete bytes only from an action. A mutation is not typed to delete bytes, but it can queue a delete that runs once its transaction commits — see Deleting.
Uploading
The browser-friendly pattern is a signed upload URL: an action mints a
short-lived PUT URL, the browser uploads to it without holding any bucket
credential, and a follow-up mutation records the key in your database. The URL
points back at your own Worker, not at R2 — that is the point of it, since the
request still passes your app's gates (auth, storage rules, rate limits) before
the Worker verifies the signature and streams the bytes into the bucket. The
trade-off is that the bytes flow through the Worker.
import { action, v } from "@/lunora/_generated/server";
export const requestUpload = action.input({ key: v.string(), contentType: v.string() }).action(async ({ ctx, args: { key, contentType } }) => {
if (!ctx.auth.userId) throw new Error("must be signed in");
const url = await ctx.storage.generateUploadUrl(`avatars/${ctx.auth.userId}/${key}`, {
contentType,
expiresInSeconds: 60,
});
return { url };
});When the bytes already live server-side (fetched in the same action, generated,
or transformed), upload them directly with store, which also enforces optional
maxSize / allowedContentTypes guards:
await ctx.storage.store(key, body, { allowedContentTypes: ["image/png", "image/jpeg"], maxSize: 5_000_000 });Uploading straight to R2
When you want the bytes off the Worker's CPU and bandwidth budget entirely —
large objects, no per-request app logic — use a native S3 presigned URL
instead: @lunora/storage's getPresignedUrl(key, { method: "PUT" }) signs a
SigV4 URL that the client sends directly to R2's S3 endpoint. It needs R2 S3 API
credentials (s3: { accountId, accessKeyId, secretAccessKey, bucket }) on the
storage instance; without them the call throws. Because such a URL is a
self-contained bearer credential that never touches your Worker, it cannot be
gated by your app's storage rules and is deliberately absent from the
rule-enforced ctx.storage surface. For very large uploads,
createMultipartUpload wraps R2's native multipart API.
Serving files
getUrl(key) returns a stable public URL against the configured base. For
private objects, getSignedUrl(key, { expiresInSeconds }) returns a short-lived
URL that grants access without exposing the bucket. Because signing is
HMAC-only, you can hand one out from a query to feed a <img src> reactively:
import { query, v } from "@/lunora/_generated/server";
export const avatarUrl = query.input({ key: v.string() }).query(async ({ ctx, args: { key } }) => {
if (!(await ctx.storage.getMetadata(key))) return null;
return ctx.storage.getSignedUrl(key, { expiresInSeconds: 300 });
});To stream the body through your Worker instead, download(key) returns the R2
object — its metadata plus a body stream — or null when the object is
absent. It is not a bare ReadableStream: read it with arrayBuffer() /
text(), or pass object.body straight into a Response. getMetadata(key)
returns the size, content-type, sha256, and any custom metadata without fetching
the body. head(key) is the same body-free read one level down — the raw R2
object shape, with the etag and base64 digest an HTTP response needs. Reach for
it when you need the object's size before deciding what to fetch (resolving a
Range header is the usual case); a download() there would start a full-object
body transfer you then throw away.
Under storageRules(...), head is gated as a read exactly like
getMetadata — it reads the same object's metadata, so the same rules apply.
Named buckets
Select a non-default bucket with ctx.storage.bucket("name"); the returned
accessor scopes every operation to that bucket and keeps the same read-only /
full split as the bare ctx.storage. Bucket names are typed when you declare
them in your schema, so a typo is a compile error.
The typed names come from the schema — every v.storage("exports") column —
plus any defineStorageRule({ bucket: "exports" }), and "default" is always
there. .storage({ buckets }) on the app config binds the bucket at runtime but
is invisible to codegen (it is a runtime object of (env) => … selectors), so a
bucket declared only there needs a v.storage(...) column or a rule as well
before ctx.storage.bucket("exports") compiles.
await ctx.storage.bucket("exports").store(key, csv, { contentType: "text/csv" }); // action
const object = await ctx.storage.bucket("exports").download(key); // any contextDeleting
delete(key) removes an object and is action-only, for the same reason
writes are:
import { action, v } from "@/lunora/_generated/server";
export const removeAttachment = action.input({ key: v.string() }).action(async ({ ctx, args: { key } }) => {
await ctx.storage.delete(key);
});A mutation that removes the row pointing at an object does not need to schedule
anything: ctx.storage.deleteAfterCommit(key) queues the delete, and the
queue is flushed once the transaction commits — and never if it rolled back, so
the row and the bytes cannot disagree.
import { mutation, v } from "@/lunora/_generated/server";
export const remove = mutation.input({ id: v.id("attachments") }).mutation(async ({ ctx, args }) => {
const attachment = await ctx.db.attachments.get(args.id);
if (!attachment) {
return;
}
await ctx.db.attachments.delete(args.id);
ctx.storage.deleteAfterCommit(attachment.key);
});It returns void, not a promise: nothing has been attempted when it returns,
and the object is still there on the next line. The flush runs after the
response wherever the host can defer it, so the caller never waits on R2; the
trade is that an object can briefly outlive its row, and a failed delete leaks
the object (logged with its key) rather than failing a mutation that already
committed. ctx.storage.bucket("avatars").deleteAfterCommit(key) queues against
that bucket.
The pairing holds for a top-level mutation. Queue a delete inside a mutation
an action called through ctx.runMutation and the flush waits until the action
returns — so an action that throws afterwards leaves the row deleted (it
committed on its own) and the object in place, with nothing logged. See
@lunora/storage for the full treatment.
See also
- Queries & mutations: the contexts each storage surface attaches to.
- Scheduling: deferring a delete or upload from a mutation.
- Actions: where the full read/write surface lives.
- @lunora/storage: bucket configuration and signed-URL internals.