Attachment Fields & Storage
An attachment field does not store a file. It stores a reference to one, and the bytes live in a bucket like any other upload. Understanding that indirection explains most of the behaviour on this page.
tables:
- id: 1
name: contracts
fields:
- id: 1
name: document
type: single-attachment
bucket: documents
allowedFileTypes: [application/pdf]
maxFileSize: 10485760
Which Bucket
bucket names an entry in your buckets[] array. Omit it and the field writes to the implicit default bucket, whose visibility follows whether app.auth is configured — see Buckets Overview.
Two layers of limits now apply to the same upload, and both must pass:
| Limit | On the field | On the bucket |
|---|---|---|
| Accepted MIME types | allowedFileTypes |
allowedMimeTypes |
| Size ceiling | maxFileSize |
maxFileSize, then STORAGE_MAX_FILE_SIZE |
Declaring the tighter of the two on the field keeps the intent next to the column it constrains; the bucket limit remains the backstop for every field that points at it.
What the Column Holds
The file reaches storage through the ordinary upload endpoint, which returns a key of the form <uuid>-<filename>. That key — not the filename, not a URL — is what the column holds.
With storeMetadata: true the column instead holds an object carrying the key plus the metadata captured at upload (dimensions, duration). Either shape resolves to the same stored object; the difference is only how much you can render without a second request.
What the Record API Returns
Read a record and its attachment values come back decorated with a way to actually fetch the file. Which decoration you get depends on STORAGE_DEFAULT_ACCESS:
{
"id": 1,
"fields": {
"document": {
"key": "9f3c1e2a-7b44-4d10-9e21-8a6f0c1d2e3b-contract.pdf",
"signedUrl": "https://app.example.com/api/buckets/default/signed?path=…&op=download&expires=…&token=…",
"signedUrlExpiresAt": "2026-04-05T11:00:00Z"
}
}
}
| Storage access mode | Added to the value | Lifetime |
|---|---|---|
private (the default) |
signedUrl + signedUrlExpiresAt |
1 hour |
STORAGE_DEFAULT_ACCESS=public |
url — no token, no expiry |
Forever |
The two are mutually exclusive by design: in public mode signedUrl is deliberately absent, so a client can rely on url never containing a token and on signedUrl always meaning "this expires".
Because the URL is minted per read, a record fetched an hour ago carries a link that has since died. Re-read the record rather than caching the URL — the key is the durable handle, the URL is not.
Auto-signed URLs point at the default bucket. The URL the record API attaches is built against /api/buckets/default/signed regardless of which bucket the field declares. A field with bucket: documents therefore returns a link that will not resolve. Until that is fixed, mint the URL yourself against the right bucket with POST /api/buckets/{bucket}/sign — or keep attachment fields on the default bucket, where the returned link is correct.
Images on an Attachment
An image attachment's signed URL takes transform parameters appended to it, so one stored original serves every size a page needs:
<signedUrl>&width=150&height=150&fit=cover
The token covers the path and the expiry, not the transform query, so appending parameters never invalidates it.
Deleting
Record deletion drives file deletion — see Lifecycle & Quotas for the full table. In short: a soft delete keeps the bytes, ?purge=true removes them, ?permanent=true does not, and replacing or clearing a single-attachment value deletes the file it displaced.
Related Pages
- Attachment Fields — the field properties themselves.
- Buckets Overview — the bucket an attachment writes to.
- File Operations — the endpoint that produces the key.
- Download URLs — minting a link yourself.
- Lifecycle & Quotas — when the bytes go away.
- Form File Uploads — filling an attachment from a form.
Last updated July 27, 2026
This documentation was written with AI, so errors or outdated content are possible. Sovrium is in beta — contributions and corrections are welcome.