Skip to content
DocsconceptsDocumentation

File storage

Typed R2 buckets exposed as ctx.storage — uploading, serving via signed URLs, and deleting files from your functions.

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.

OperationQueryMutationAction
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 context

Deleting

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