# Upload field (/docs/schema/fields/upload)

---
title: Upload field
description: f.upload() holds the id of a row in an upload collection. One asset in a varchar(36), or a gallery through a junction, and the admin renders a file picker rather than a record picker.
kind: reference
package: questpie
---

One factory, three forms. The form decides whether this row owns a column.

| Form                    | Column on this row           | Filters with   |
| ----------------------- | ---------------------------- | -------------- |
| `f.upload()`            | `varchar(36)`, one asset id  | `belongsToOps` |
| `f.upload().multiple()` | `jsonb`, a list of asset ids | `multipleOps`  |
| `f.upload({ through })` | none, the field goes virtual | `toManyOps`    |

## Signature

```ts
f.upload(config?: {
	to?: string; // default "assets"
	through?: string;
	sourceField?: string;
	targetField?: string;
	mimeTypes?: string[];
	maxSize?: number;
});
```

## Options

Every key is optional.

| Option        | Type       | Default    | Effect                                                                           |
| ------------- | ---------- | ---------- | -------------------------------------------------------------------------------- |
| `to`          | `string`   | `"assets"` | The upload collection the id points at. Reaches the admin as `targetCollection`. |
| `through`     | `string`   | none       | Junction collection. Drops the column, makes the field virtual and many-to-many. |
| `sourceField` | `string`   | none       | The junction column holding this row's id. Required whenever `through` is set.   |
| `targetField` | `string`   | none       | The junction column holding the asset id. Required whenever `through` is set.    |
| `mimeTypes`   | `string[]` | none       | Reaches the admin control as its `accept`, which narrows the file picker.        |
| `maxSize`     | `number`   | none       | Reaches the admin control as its `maxSize`, in bytes.                            |

<Callout type="warn" title="The picker narrows, the collection enforces">
	`mimeTypes` and `maxSize` shape one field's file picker. The limit the server
	checks on every upload is the target collection's
	`.upload({ allowedTypes, maxSize })`, and a field cannot widen past it.
</Callout>

## The target has to be an upload collection

`to` names a collection, and that collection has to exist and carry `.upload()`.
That call is what adds `key`, `filename`, `mimeType`, `size` and `visibility` to
it, resolves a `url` onto every row that carries bytes, and hands it
`crud.upload()` and `crud.uploadMany()`. Point `to` at a collection without it
and `POST /:collection/upload` answers `400 upload.collectionNotSupported`,
while `crud.upload()` is never attached to that collection in the first place.

You already have one. `starterModule` ships an `assets` collection carrying
`.upload({ visibility: "public" })`, which is why the default is `"assets"`.
Storage is a core service with a filesystem default at `./uploads`, so an app
that configures no adapter still stores bytes. See
[Uploads](/docs/schema/collections/uploads) for the collection side and
[Storage adapters](/docs/infrastructure/storage) for pointing the bytes elsewhere.

## Single

`varchar(36)` on this table, nullable until `.required()`. The value you read and
write is one asset id.

```ts
cover: f.upload().required(),          // the default "assets" collection
manual: f.upload({ to: "documents" }), // any other upload collection
```

Hydrate the asset itself the way you would any belongsTo, with
`with: { cover: true }`. Uploads set `inheritAccess`, so the asset row rides on
the parent row's read decision rather than being denied on its own.

## Through a junction

```ts
gallery: f.upload({
	through: "post_assets",
	sourceField: "post",
	targetField: "asset",
}),
```

`through` sets `virtual: true` and `columnFactory: null`, so this table gets no
column and the links live in the junction collection you name. The runtime schema
becomes `z.array(z.string().uuid())`.

`.localized()` does nothing on a `through` field. Storage location is inferred
from `virtual` before `localized` is tested, so on a virtual field the call is
accepted and dropped.

<Callout type="warn" title="Name both junction columns">
	The resolver reads `sourceField` and `targetField` off the relation and
	returns early when either is missing. Omit them and the gallery hydrates as
	nothing, with no error raised.
</Callout>

## `.multiple()`

```ts
images: f.upload().multiple(),
```

The only method `upload` adds on top of the
[shared modifiers](/docs/schema/fields). It swaps the `varchar(36)` for a `jsonb`
column holding a list of asset ids, and swaps `belongsToOps` for `multipleOps`.
The list lives on this row, so there is no junction table to name.

Reach for `through` instead when you want the links in their own table, which is
what you need if the junction carries data of its own, an ordering column for
instance.

The metadata is the same either way. `relationType` is derived from `through`
alone, so a `.multiple()` field still reports `belongsTo` and the admin still
builds the single-file picker.

## The admin control

`UploadField`, from `@questpie/admin`. It is chosen off the `isUpload` flag in
the field's metadata, not off the type name, which is why an upload renders a
file picker where `f.relation()` renders a record picker.

Configure it with `.admin()`, the field extension that module registers.

| Key              | Type                                 | Default     | Effect                                                              |
| ---------------- | ------------------------------------ | ----------- | ------------------------------------------------------------------- |
| `accept`         | `string \| string[]`                 | none        | File types the dropzone accepts.                                    |
| `maxSize`        | `number`                             | none        | Bytes the dropzone refuses past.                                    |
| `showPreview`    | `boolean`                            | `true`      | Render the stored asset inline. Single form only.                   |
| `editable`       | `boolean`                            | `true`      | Offer the alt and caption sheet.                                    |
| `previewVariant` | `"card" \| "compact" \| "thumbnail"` | `"compact"` | Preview shape. Ignored in a `through` form, where `layout` decides. |
| `maxItems`       | `number`                             | none        | Cap on files, `through` form only.                                  |
| `orderable`      | `boolean`                            | `false`     | Drag to reorder, `through` form only.                               |
| `layout`         | `"grid" \| "list"`                   | `"grid"`    | Preview layout, `through` form only.                                |

```ts
cover: f.upload().admin({ accept: ["image/*"], maxSize: 5_000_000 }),
```

`accept` and `maxSize` are the same two dials as the factory's `mimeTypes` and
`maxSize`, so the line above and `f.upload({ mimeTypes: ["image/*"] })` reach
the same prop. Set both and `.admin()` wins, being the more specific of the two.

<Callout type="info" title="Both layers, or neither holds">
	`.admin()` is typed `unknown`, and everything it sets is browser-side. A
	client that skips the admin posts whatever it likes. The enforced limits are
	`.upload({ allowedTypes, maxSize })` on the target collection.
</Callout>

## Filtering

A single upload carries `belongsToOps`. Operands are asset ids.

| Operator               | Operand          | Matches                                  |
| ---------------------- | ---------------- | ---------------------------------------- |
| `eq` / `ne`            | `string`         | The stored id equals, or does not.       |
| `not`                  | `string \| null` | Same as `ne`, and `null` means "is set". |
| `in` / `notIn`         | `string[]`       | The stored id is in the list, or is not. |
| `isNull` / `isNotNull` | `boolean`        | The column is empty, or filled.          |
| `is` / `isNot`         | related `where`  | The linked asset matches, as `EXISTS`.   |

```ts
const { docs } = await app.collections.posts.find({
	where: { cover: { is: { mimeType: { eq: "image/png" } } } },
});
```

A `through` upload carries `toManyOps`, and the query builder turns `some`,
`none` and `every` into `EXISTS` clauses over the junction.

| Operator | Operand         | Matches                                      |
| -------- | --------------- | -------------------------------------------- |
| `some`   | related `where` | At least one linked asset does.              |
| `none`   | related `where` | No linked asset does.                        |
| `every`  | related `where` | Every linked asset does.                     |
| `count`  | `number`        | Nothing. Throws, there is no column to read. |

## Types

An upload contributes `string` to the row, insert and update shapes.
`.required()` makes it non-null and required on insert.

```ts
type Post = typeof posts.$infer.select;
//   ^? { id: string; title: string; cover: string | null; ... }
```

`through` does not move that. `UploadFieldState` pins `data: string` and the many
branch returns the same state, so a junction-backed upload still types as
`string` even though the value it carries is an array. `.multiple()` is typed
`(): any` and gives up the state entirely. `f.relation().manyToMany()` transitions
properly where this does not.

The field's `z.string().uuid()` never reaches the collection. Insert and update
schemas skip relation and upload keys on purpose, since real ids are not always
uuids.

## Related

[Uploads](/docs/schema/collections/uploads) turns a collection into the byte store
this field points at.

[Relations](/docs/schema/relations) covers `with:` hydration and the relation
filters both forms borrow.

[Fields](/docs/schema/fields) has the shared modifiers and every type.
