Storage

File upload, download, and management with ctx.storage

Gencow provides a built-in file storage system. Upload private attachments, publish website assets, create short-lived preview/download URLs, and manage storage through ctx.storage.

Storage is private by default in hosted runtimes. A bare /api/storage/<id> URL only works anonymously when the storage object is explicitly marked public. Private files need a short-lived read grant URL.

Uploading Files

From a Write Procedure (Backend)

import { v } from "@gencow/core";
import { procedure } from "./runtime";

export const upload = procedure.mutation
    .name("files.upload")
    .input(
        v.object({
            file: v.file(),
        }),
    )
    .output(
        v.object({
            storageId: v.string(),
            url: v.string(),
            urlExpiresAt: v.dateTime(),
            name: v.string(),
            size: v.number(),
            type: v.string(),
        }),
    )
    .handler(async ({ context: ctx, input }) => {
        // Store file -> private by default, returns a unique storageId
        const storageId = await ctx.storage.store(input.file);

        // Create a short-lived browser URL for private preview/download
        const readGrant = await ctx.storage.createReadGrant(storageId, {
            ttlSeconds: 300,
            disposition: "inline",
        });

        const file = input.file;
        return {
            storageId,
            url: readGrant.url,
            urlExpiresAt: readGrant.expiresAt,
            name: file instanceof File ? file.name : "upload.bin",
            size: file.size,
            type: file.type || "application/octet-stream",
        };
    });

How it works: Declare file fields with v.file() in .input(). The server validates and types input.file as a File or Blob. On the client, pass { file } to useMutation (multipart is handled automatically) or send FormData with a matching field name.

Store from Buffer

// Useful for programmatic file creation (PDFs, exports, etc.)
const csvContent = "name,email\nJohn,[email protected]";
const buffer = Buffer.from(csvContent);

const storageId = await ctx.storage.storeBuffer(
    buffer,
    "export.csv",
    "text/csv",
);

const grant = await ctx.storage.createReadGrant(storageId, { disposition: "attachment" });

Public Website Assets

Use public visibility only for files that are intentionally readable by anyone who has the URL, such as hero images, logos, gallery images, certificates, and public downloads.

const storageId = await ctx.storage.store(file, {
    visibility: "public",
});

const url = ctx.storage.getPublicUrl(storageId);
// -> "/api/storage/<uuid>"

You can also publish generated files:

const storageId = await ctx.storage.storeBuffer(
    imageBuffer,
    "hero.png",
    "image/png",
    { visibility: "public" },
);

const url = ctx.storage.getPublicUrl(storageId);

Serving Files

ctx.storage.getUrl() returns the logical bare storage path:

const url = ctx.storage.getUrl(storageId);
// -> "/api/storage/<uuid>"
// Full URL: "http://localhost:5456/api/storage/<uuid>"

In hosted runtimes, that bare URL is anonymous-readable only for public storage objects. Private files return 403 unless the URL includes a valid read grant:

const preview = await ctx.storage.createReadGrant(storageId, {
    ttlSeconds: 300,       // clamped to 1..300, default 60
    disposition: "inline", // "inline" preview or "attachment" download
});

const download = await ctx.storage.createReadGrant(storageId, {
    ttlSeconds: 300,
    disposition: "attachment",
});
Use case Recommended API
Public website image/download store(file, { visibility: "public" }) + getPublicUrl()
Private attachment preview createReadGrant(id, { disposition: "inline" })
Private attachment download createReadGrant(id, { disposition: "attachment" })
Server-side processing ctx.storage.get(id)

Files are served with:

  • Correct Content-Type header
  • Public files: Cache-Control: public, max-age=31536000, immutable
  • Private read grants: Cache-Control: private, max-age=<remaining ttl> or private, no-store
  • UTF-8 filename support

Public inline rendering is restricted to safe image MIME types. Public text/html, JavaScript, SVG, and other scriptable content is served as an attachment even when the object is public.

Changing Visibility

await ctx.storage.setVisibility(storageId, "public");
await ctx.storage.setVisibility(storageId, "private");

Changing a file from public to private closes fresh bare URL requests immediately. Existing browser/CDN caches may still retain bytes until their cache TTL expires.

Deleting Files

import { procedure } from "./runtime";
import { v } from "@gencow/core";

export const remove = procedure.mutation
    .name("files.remove")
    .input(v.object({ storageId: v.string() }))
    .handler(async ({ context: ctx, input }) => {
        ctx.auth.requireAuth();
        await ctx.storage.delete(input.storageId);
    });

React File Upload

import { useMutation } from "@gencow/react";
import { api } from "../gencow/api";

function FileUpload() {
    const { mutate: upload, isPending } = useMutation(api.files.upload);

    const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
        const file = e.target.files?.[0];
        if (!file) return;

        const result = await upload({ file });
        console.log("Uploaded:", result.url);
    };

    return (
        <div>
            <input
                type="file"
                onChange={handleFileChange}
                disabled={isPending}
            />
            {isPending && <span>Uploading...</span>}
        </div>
    );
}

Listing Files

Gencow keeps two separate concerns:

  1. Runtime inventory (managed by Gencow) — every file you store is recorded in an internal catalog so quota accounting works and so you can recover from "I lost the storageId" situations. Read it through ctx.storage.listMeta().
  2. Product data (managed by your app) — if your app has a files table or any other table that ties files to users, projects, etc., that's your responsibility to maintain. ctx.storage.store() does not auto-populate that table.

Runtime inventory — ctx.storage.listMeta()

Use listMeta for admin/cleanup workflows, reconciliation jobs, or any time you need to enumerate stored objects without depending on an app-owned table:

import { procedure } from "./runtime";

export const adminList = procedure.query
    .name("admin.storage.list")
    .handler(async ({ context: ctx }) => {
        ctx.auth.requireAuth();

        const page = await ctx.storage.listMeta({
            limit: 20,
            order: "desc", // newest first; default
        });

        return {
            items: page.items.map(f => ({
                ...f,
                url: f.visibility === "public"
                    ? ctx.storage.getPublicUrl(f.id)
                    : null,
            })),
            nextCursor: page.nextCursor,
        };
    });

To walk the whole catalog, pass nextCursor back in:

let cursor: string | null = null;
do {
    const page = await ctx.storage.listMeta({ limit: 100, cursor });
    for (const file of page.items) {
        // … process each file
    }
    cursor = page.nextCursor;
} while (cursor !== null);
  • limit is clamped to [1, 100] (default 20).
  • Cursors are opaque tokens — treat them as strings. Reusing a cursor with a different order (e.g. desc cursor on an asc call) returns Invalid cursor.
  • Returns metadata only (id, name, size, type) — no file buffers. Cheap for large catalogs.

Filesystem-only deployments: if the runtime has no SQL catalog wired up, listMeta throws StorageListUnsupportedError. Plain local-dev gencow dev --local includes the catalog, so this only affects custom adapters.

App-owned files table (optional)

If your app needs file rows linked to users or scoped by product rules, define and write to your own table in a mutation:

import { v } from "@gencow/core";
import { procedure } from "./runtime";

export const upload = procedure.mutation
    .name("files.upload")
    .input(
        v.object({
            file: v.file(),
        }),
    )
    .handler(async ({ context: ctx, input }) => {
        const file = input.file;

        const storageId = await ctx.storage.store(file);
        const readGrant = await ctx.storage.createReadGrant(storageId, {
            ttlSeconds: 300,
            disposition: "inline",
        });

        const [row] = await ctx.db.insert(files).values({
            storageId,
            userId: user.id,
            name: file instanceof File ? file.name : "upload.bin",
        }).returning();

        return { ...row, url: readGrant.url };
    });

export const list = procedure.query
    .name("files.list")
    .handler(async ({ context: ctx }) => {
        const rows = await ctx.db
            .select()
            .from(files)
            .where(eq(files.userId, user.id))
            .orderBy(desc(files.createdAt));
        return Promise.all(rows.map(async (r) => {
            const readGrant = await ctx.storage.createReadGrant(r.storageId, {
                ttlSeconds: 300,
                disposition: "inline",
            });
            return { ...r, url: readGrant.url };
        }));
    });

When to use which: product features and per-user authorization need an app table. Operational tooling (orphan-blob detection, quota dashboards, "list everything I uploaded") uses listMeta.

Image Optimization

Gencow automatically optimizes images served through storage. No configuration needed — it works for public asset URLs and grant-backed private image URLs.

Auto WebP

When a browser supports WebP (most modern browsers do), Gencow automatically converts your images to WebP format for smaller file sizes:

Browser requests: /api/storage/<uuid>
  → Accept: image/webp header detected
  → Image converted to WebP on-the-fly
  → Cached for subsequent requests

Result: PNG → WebP = up to 93% smaller
        JPEG → WebP = up to 57% smaller

Smart fallback: If the WebP version is larger than the original (rare, well-compressed JPEGs), the original is served instead. You never get a worse result.

For public objects this works even when your app is sleeping — images are served directly by the platform without waking your app, so there's zero cold start for image requests. Private objects still require a valid read grant.

Custom Settings

You can customize Auto WebP behavior per app using the CLI:

# Set max width — images wider than this are downscaled
gencow config set image.maxWidth 1280

# Set quality (1-100, default: 75)
gencow config set image.quality 85

# Check current settings
gencow config get image

# Reset to tier defaults
gencow config reset image

These settings are clamped by your tier's maximum:

Hobby tier (max 1920px):
  Set 1280 → Applied: 1280 ✅ (within limit)
  Set 3840 → Applied: 1920 ✅ (clamped to tier max)

Startup tier (max 3840px):
  Set 1280 → Applied: 1280 ✅
  Set 3840 → Applied: 3840 ✅

Settings take effect immediately for new image requests. Previously cached images are unaffected (they use a different cache key).

Image Transforms (Startup & Enterprise)

On Startup and Enterprise plans, you can transform images on-the-fly using URL parameters. Public assets use the bare URL. Private assets use a read grant URL; append transform parameters with & after the grant parameter.

/api/storage/<uuid>?w=800           → Resize to 800px wide
/api/storage/<uuid>?w=400&h=300     → Resize to 400×300
/api/storage/<uuid>?f=avif          → Convert to AVIF
/api/storage/<uuid>?q=60            → Set quality to 60%
/api/storage/<uuid>?w=800&f=webp&q=70  → Combine transforms
/api/storage/<uuid>?grant=<token>&w=800  → Private image with read grant

Transform Parameters

Parameter Description Values Example
w Width (px) 1–4096 ?w=800
h Height (px) 1–4096 ?h=600
f Output format webp, avif, jpeg, png ?f=webp
q Quality 1–100 (default: 80) ?q=70
fit Resize mode cover, contain, fill, inside ?fit=contain

Images are never upscaled — withoutEnlargement is always applied.

Tier Limits

Feature Hobby (Free) Startup Enterprise
Auto WebP ✅ Free
Auto downscale 1920px max 3840px max Unlimited
Resize (w, h) ❌ 403
Format convert (f) WebP only All formats All formats
Quality control (q) ❌ 403

Hobby plan users get automatic optimization (WebP + downscale to 1920px) for free. Requesting resize or quality parameters on Hobby returns:

{
    "error": "Image resize requires Pro plan",
    "code": "PLAN_LIMIT",
    "upgrade": "https://gencow.app/pricing"
}

The API error message may still reference the legacy internal plan id during rollout. Hobby users need a Startup plan or higher for explicit resize/transform parameters.

Caching

Transformed images are cached on disk. The same URL always returns the cached version:

uploads/.cache/<uuid>_auto_webp_mw1920.webp   → Auto WebP cache (Hobby)
uploads/.cache/<uuid>_w800_fwebp_q80.webp     → Transform cache

When you delete a file with ctx.storage.delete(), all its cached transforms are automatically cleaned up.

Supported Image Types

Auto WebP and transforms work on:

  • JPEG / JPG
  • PNG
  • WebP (transforms only, Auto WebP skipped)

Non-image files (PDF, ZIP, etc.) and SVG/GIF are always served as-is.

Limits

Limit Hobby Startup Enterprise
Max file size 50 MB 50 MB 50 MB
Storage quota 1 GB 20 GB Custom
Image cache 200 MB 1 GB 5 GB
Supported types Any Any Any

Exceeding the quota throws: Storage quota exceeded: 950MB used + 100MB new = 1.05GB, quota is 1GB

Storage API Reference

Method Description
ctx.storage.store(file, filenameOrOptions?) Store a File/Blob. Default visibility is private; pass { visibility: "public" } for public assets
ctx.storage.storeBuffer(buffer, filename, type?, options?) Store from Buffer. Default visibility is private; pass { visibility: "public" } for public assets
ctx.storage.getUrl(storageId) Get logical bare storage URL. Anonymous-readable only when the object is public
ctx.storage.getPublicUrl(storageId) Get bare URL for an intentionally public object
ctx.storage.createReadGrant(storageId, { ttlSeconds?, disposition? }) Create short-lived private preview/download URL
ctx.storage.getMeta(storageId) Get file metadata (id, name, size, type, visibility)
`ctx.storage.setVisibility(storageId, "public" "private")`
ctx.storage.listMeta({ limit?, cursor?, order? }) List runtime inventory with cursor pagination → { items, nextCursor }
ctx.storage.delete(storageId) Delete a file + all cached transforms

Next Steps