# Uploads (/docs/schema/collections/uploads)

---
title: Uploads
description: Calling .upload() turns a collection into the place file bytes live, adding the storage columns, a resolved url on every row, the upload methods and the file routes.
kind: guide
package: questpie
---

Where do the bytes go? Into a collection of their own. You mark one collection
as the store, and every other collection points at it with a field.

## Make the store

```ts title="src/questpie/server/collections/media.ts"
import { collection } from "#questpie/factories";

export const media = collection("media")
	.fields(({ f }) => ({
		alt: f.text().label("Alt text"),
	}))
	.title(({ f }) => f.alt)
	.upload({
		visibility: "private", // default "public"
		maxSize: 10 * 1024 * 1024,
		allowedTypes: ["image/*", "application/pdf"],
	});
```

Run `questpie generate` and `questpie push`, then store a file:

```ts
const asset = await app.collections.media.upload(file, ctx);
console.log(asset.url);
```

`upload(file, ctx, extra?)` and `uploadMany(files, ctx, extra?)` exist only on
collections that called `.upload()`, and the third argument sets other fields on
the created rows. A `file` is `{ name, type, size, arrayBuffer?, stream? }`.
Give it a `stream` for anything large, which is used in preference to
`arrayBuffer`.

## What .upload() added

Five columns. `key`, `filename`, `mimeType` and `size` are nullable, so a row
can exist before its blob. `visibility` is `notNull` and defaults to
`"public"`. The select type gains `url: string`, and the two file routes start
answering instead of rejecting the collection: `POST /:collection/upload` and
`GET /:collection/files/*key`. Storage lifecycle hooks arrive too. They build
the URL on read, check the object really exists on write, and clean the old
object up when a row changes or goes away.

## The options

| Option         | Meaning                                                            |
| -------------- | ------------------------------------------------------------------ |
| `visibility`   | `"public"` or `"private"`, default `"public"`. Governs bytes only. |
| `maxSize`      | Largest accepted file, in bytes.                                   |
| `allowedTypes` | MIME patterns. Wildcards such as `"image/*"` work.                 |

A private file gets a signed URL instead of a plain one, built from
`app.config.app.url` and a token. The token needs `app.config.secret`, and
without it `url` comes back `undefined`. It expires after
`app.config.storage.signedUrlExpiration` seconds, one hour by default.

<Callout type="warn" title="visibility gates bytes, not rows">
	Reading and listing the upload rows still runs the normal `.access()` chain.
	Byte serving resolves `access.serve`, then an explicit collection `read` rule,
	then `defaultAccess.serve`, then allows. Private files also need a valid token
	whatever that rule says.
</Callout>

## The field is not the method

`f.upload({ to: "media" })` is a **field**. It stores a typed relation from
this row to a separate upload collection, defaulting to one named `assets`, and
gives you an admin picker. `.upload()` is a **builder method** and makes this
collection the byte store.

```ts
collection("posts").fields(({ f }) => ({
	title: f.text().required(),
	cover: f.upload({ to: "media" }), // points at the store above
}));
```

Reach for the field when a post has a cover image. Reach for the method when
you are building the media library itself. The field type has its own page,
[`f.upload()`](/docs/schema/fields/upload).

## Deleting

On a collection without soft delete, deleting a row deletes its stored object
after the transaction commits. With soft delete on, the object stays, because
the row can come back. `purgeById` is what finally queues the object for
cleanup. Replacing a row's `key` queues the old object too.

## Next

**[Storage](/docs/infrastructure/storage)** covers the adapters behind all of this,
local disk and S3-compatible buckets.
