Upload field
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.
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
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. |
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.
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 for the collection side and
Storage adapters for pointing the bytes elsewhere.
Single
varchar(36) on this table, nullable until .required(). The value you read and
write is one asset id.
cover: f.upload().required(), // the default "assets" collection
manual: f.upload({ to: "documents" }), // any other upload collectionHydrate 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
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.
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.
.multiple()
images: f.upload().multiple(),The only method upload adds on top of the
shared modifiers. 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. |
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.
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.
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. |
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.
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 turns a collection into the byte store this field points at.
Relations covers with: hydration and the relation
filters both forms borrow.
Fields has the shared modifiers and every type.
Rich text field
The TipTap editor field. It stores a structured document in jsonb, or a markdown string in text, and renders a configurable WYSIWYG editor in the admin.
Temporal values
Three field types, two value shapes. An instant crosses every boundary as a `Date`. A calendar day and a clock reading cross as exact strings and never become one.