QUESTPIE
SchemaCollections

Uploads

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.

View markdown

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

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:

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

OptionMeaning
visibility"public" or "private", default "public". Governs bytes only.
maxSizeLargest accepted file, in bytes.
allowedTypesMIME 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.

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.

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.

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().

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 covers the adapters behind all of this, local disk and S3-compatible buckets.

On this page