QUESTPIE
SchemaFields

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.

View markdown

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

FormColumn on this rowFilters with
f.upload()varchar(36), one asset idbelongsToOps
f.upload().multiple()jsonb, a list of asset idsmultipleOps
f.upload({ through })none, the field goes virtualtoManyOps

Signature

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

Options

Every key is optional.

OptionTypeDefaultEffect
tostring"assets"The upload collection the id points at. Reaches the admin as targetCollection.
throughstringnoneJunction collection. Drops the column, makes the field virtual and many-to-many.
sourceFieldstringnoneThe junction column holding this row's id. Required whenever through is set.
targetFieldstringnoneThe junction column holding the asset id. Required whenever through is set.
mimeTypesstring[]noneReaches the admin control as its accept, which narrows the file picker.
maxSizenumbernoneReaches 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 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

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.

KeyTypeDefaultEffect
acceptstring | string[]noneFile types the dropzone accepts.
maxSizenumbernoneBytes the dropzone refuses past.
showPreviewbooleantrueRender the stored asset inline. Single form only.
editablebooleantrueOffer the alt and caption sheet.
previewVariant"card" | "compact" | "thumbnail""compact"Preview shape. Ignored in a through form, where layout decides.
maxItemsnumbernoneCap on files, through form only.
orderablebooleanfalseDrag 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.

OperatorOperandMatches
eq / nestringThe stored id equals, or does not.
notstring | nullSame as ne, and null means "is set".
in / notInstring[]The stored id is in the list, or is not.
isNull / isNotNullbooleanThe column is empty, or filled.
is / isNotrelated whereThe 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.

OperatorOperandMatches
somerelated whereAt least one linked asset does.
nonerelated whereNo linked asset does.
everyrelated whereEvery linked asset does.
countnumberNothing. 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.

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.

On this page