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.
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
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
| 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.
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.
Options
One .options() call decides whether a collection stamps timestamps, keeps deleted rows, keeps history, moves through publish stages, and which Postgres schema its tables live in.
Optimistic concurrency
Two people editing one row is a race. Turn this on and every write carries the revision it expects, so the second one is rejected rather than silently overwriting the first.