Storage
Object storage for user uploads — avatars, attachments, exports. Buckets are declared in project.toml, so what storage exists is a reviewable file; access goes through your functions via the storage capability, so who may touch which path is code you wrote. Locally each bucket is a real directory under .snoozestack/storage/; hosted, the same declarations are backed by managed object storage — your application code doesn't change.
Files could technically go in the database as blob columns, and that is almost always the wrong call. A database is tuned for many small rows read transactionally; a 4 MB photo in a row bloats every backup and pushes useful data out of cache. Object storage is built for the opposite shape — few, large, immutable blobs, served directly.
The pattern worth internalizing, because it's how essentially every production app handles uploads: the bytes go to storage, and the database row holds only the path. Your messages table stores photo_path, not the image. Queries stay small, the file is served without streaming through your handler, and the two are authorized independently.
Access control is the same story as Permissions everywhere else: there is no policy language to learn, because there is no client-facing write route to police. A function mints the signed operation, and the function knows who's asking.
Declaring buckets#
[[storage]]name = "avatars"public = true # objects readable at a stable URL [[storage]]name = "documents" # private (the default): reads need a signed URLsnoozestack publish (or snoozestack storage push alone) creates or updates the hosted buckets. Push never deletes: removing a declaration never silently destroys hosted files — snoozestack storage delete is the only file-destroying command, and it prompts. Each namespace (dev, live, every preview) holds genuinely separate files.
- User-generated media
- Avatars, post images, message attachments. Have the function namespace the path by the caller's id (
<uid>/<file>) so every user gets their own area — enforced by the code that mints the path, in one place. - Private documents
- Invoices, contracts — a private bucket plus short-lived signed URLs, so a leaked link stops working instead of exposing the file forever.
- Generated artifacts
- CSV exports, PDF reports, thumbnails: a function writes the file with
capabilities.storage.put()and hands back a signed URL to download it. - Public static assets
- Logos, product images — a public bucket serves them at a stable URL with no auth round-trip. (For a whole site of files, see Websites instead.)
| Surface | Availability |
|---|---|
| Portal UI | Read-only bucket and object listing (hosted console and local dashboard alike); files move with the CLI and signed operations |
| CLI | snoozestack storage list/push/create/public/private/delete, each scoped by --dev/--preview |
| SDK / HTTP | No storage client, deliberately — the app asks your function, then PUTs/GETs the signed URL directly |
The capability surface#
A function declaring the storage capability can reach every declared bucket:
await capabilities.storage.put("avatars", `${user.id}/avatar.png`, bytes);const file = await capabilities.storage.get("avatars", key);const files = await capabilities.storage.list("avatars", `${user.id}/`);await capabilities.storage.delete("avatars", key); // Time-limited read for a private object:const url = await capabilities.storage.signedUrl("documents", key, { expiresIn: 3600 }); // Hosted: authorize an upload without the bytes touching your function:const upload = await capabilities.storage.signedUploadUrl("avatars", `${user.id}/avatar.png`);Every method is await-able in both dev and hosted, so the same function code runs in both. signedUploadUrl is the hosted path for large uploads — the client PUTs straight against it; in dev, upload through the function itself.
The usual pattern: upload, then store the path#
// 1. Ask your function for an upload slot. It namespaces the path by the// caller's own id — the client never chooses where it may write.const { data: slot } = await snoozestack.functions.invoke("photo-upload-url", { body: { contentType: file.type },}); // 2. Bytes → storage, directly against the signed URL.await fetch(slot.url, { method: "PUT", body: file, headers: { "content-type": file.type } }); // 3. Path → database, via a function. The row stays tiny.await snoozestack.functions.invoke("post-message", { body: { body: text, photo_path: slot.path },}); // 4. Reading back from a public bucket: build the URL from the stored path.const url = `${SNOOZESTACK_URL}/storage/v1/object/public/photos/${slot.path}`;Store the path, not the full URL. Paths stay valid if the bucket's visibility changes, if you attach a custom domain, or if a private bucket later needs signed URLs — a full URL baked into a row has to be rewritten in all of those cases.
Sharing files: public vs. signed URLs#
A public bucket serves objects at a stable URL, for anyone, forever — right for content that is genuinely public. A private bucket serves only through signedUrl(), whose links expire — right for anything where “who may see this” is a question. If you find a function proxying file bytes to apply a rule, mint a signed URL instead: the rule runs in the function, the bytes don't.