Chelabase
Storage

Storage

Files in buckets, guarded by row-level security, served through the gateway at /storage/v1.

Every database has object storage: files in buckets, each file an object with a name such as avatars/2b1c…/me.png. Objects are stored in an S3-compatible object store (SeaweedFS in the self-hosted install, or Amazon S3), and their metadata is a row in the branch's own Postgres database, in the storage schema.

That has three consequences you'll see throughout these pages:

  • Postgres decides who may do what. Every storage call runs as the caller's role, with its claims, so row-level security policies on storage.buckets and storage.objects decide. With no policy, nothing is visible or writable.
  • Branches copy files for free. A branch copies the metadata rows with the rest of the database, and the stored bytes are shared until one side changes them. See Branches and files.
  • The object store is never exposed. Apps talk to the gateway; only the storage service holds the object store's credentials. Signed and public URLs point at the gateway too. See the architecture page.

How to call it

FromHow
Your appclient.storage in @chelabase/client, or the HTTP API at /storage/v1 with a data API key as the bearer
A browser with no keyA signed URL, a one-time upload URL, or a public bucket's path
StudioThe Storage tab of a branch: buckets, the object browser, uploads and live updates
Another serviceWebhooks for every object created, updated, moved or deleted

A key belongs to one branch, so each call works on that branch's objects. A public key runs as anon, a secret key and a signed-in app user as authenticated (or the key's role). Owner-level roles are refused (403 role_not_allowed).

The HTTP API's contract is openapi/storage.json. Who may call each route is in the access table.

Buckets

A bucket is a row of storage.buckets:

ColumnDefaultWhat it does
namerequiredUnique per database. Objects are addressed as /{bucket}/{name}. 1 to 63 characters from a-z, 0-9, ., _ and -, starting with a letter or digit (bucket names).
publicfalseObjects are served without a key at their public path.
file_size_limitnoneThe largest object, in bytes. Larger uploads, copies and moves into the bucket are refused with 413 too_large.
allowed_mime_typesnoneA list such as ["image/png", "image/*"]. An upload must declare an allowed type, and its first bytes must be recognised as one too; otherwise 415 mime_not_allowed.
write_oncefalseSee Write-once buckets.

Creating, changing and deleting a bucket runs as the caller like everything else, so it needs an INSERT, UPDATE or DELETE policy on storage.buckets. In practice that's a secret key on a server, or the owner in Studio. A bucket that still holds objects can't be deleted (409 bucket_not_empty).

Bucket names

The API creates and renames buckets only under this grammar: 1 to 63 characters from a-z, 0-9, ., _ and -, starting with a letter or digit. list, copy, move, sign, public, upload, resolve and session are reserved.

storage.buckets.name is plain text, so SQL can create a bucket outside the grammar (Docs, user files, a name over 63 bytes). Such a bucket can be read, listed and downloaded, but every object write or delete through the API answers 400 invalid_request: uploads, upload URLs, resumable uploads, deletes, moves, and copies into it. A copy out of it into a valid bucket is allowed, since a copy writes only its destination. Remove its objects with SQL, or create a bucket under a valid name and copy them across.

Every object route needs the caller to see the bucket: a SELECT policy on storage.buckets that shows it. Without one, the bucket is 404 bucket_not_found (missing and hidden look the same).

Objects

An object name is 1 to 1024 bytes of UTF-8, with no control characters, no leading /, no empty segment (a//b) and no segment made only of dots. / separates folders: listing with folders groups names at the next /.

Each upload makes a new version (version_id). An upload to a name that's taken is 409 conflict unless it asks to replace (upsert), which needs an UPDATE policy as well as INSERT. A move or rename changes only the metadata; a copy stores a new version at the destination.

Every download is served with X-Content-Type-Options: nosniff and Content-Security-Policy: sandbox, and as an attachment unless its type is a safe inline type: images (not SVG), video, audio, PDF and plain text. So no stored file can run script on the API's origin, whatever type it was uploaded as. ?download (or ?download=<file name>) always makes it an attachment.

Uploads and limits

  • Single uploads stream the body of any length (chunked included) in one request. Bytes are counted as they arrive, so file_size_limit holds whatever Content-Length says.
  • Resumable uploads go up in parts: start a session (POST /storage/v1/upload/{bucket}/{name}), send parts 1 to 10000 of 5 MiB to 100 MiB each (the last may be smaller; a part sent again replaces it), then complete it. Completing checks the policies and the bucket's limits again and makes the object atomically; a refusal (403, 413, 415) aborts the upload. A session lasts 24 hours, then its parts answer 410 upload_expired and the sweep removes them. A session belongs to its caller: anyone else gets 404.
  • Idle bodies. An upload whose body sends nothing for 60 seconds fails with 408 request_timeout and frees its memory (CHELABASE_STORAGE_BODY_IDLE_SECS).
  • In-flight cap. Each storage process holds at most 1 GiB of upload buffers at once (CHELABASE_STORAGE_MAX_INFLIGHT_BYTES). Past it, uploads are refused with 503 busy: retry them. The client retries for you.

The client picks single or resumable uploads by size and resumes after a failure. See Client and server.

Write-once buckets

A bucket with write_once = true keeps every object exactly as first uploaded:

  • an object can be uploaded once, then never overwritten, moved, renamed or deleted, by anyone. The database owner and Studio's owner mode are refused too (409 write_once). A resumable upload's completion refuses an existing name the same way;
  • write_once can be turned on at any time and never off again;
  • a write-once bucket holding objects can't be renamed or deleted.

public stays switchable. That's the owner's takedown lever: turning it off takes every public URL in the bucket offline (404) without changing any object, and turning it on again brings them back. Use a public write-once bucket for links other sites keep, such as embedded images: the bytes behind a URL never change.

Errors

Errors use the data API's envelope, {"error": {"code", "message", …}}. The client throws them as StorageError, with the server's code as serverCode beside its own stable code (see Client and server).

StatusCodeWhen
400invalid_request, invalid_partsA malformed body, name, Content-Type or TTL; a resumable upload with a gap or a short part that isn't the last
401unauthorized, invalid_token, token_expiredNo or a bad key; a signed URL's token is malformed or expired
403not_allowed, permission_deniedNo policy allows it
403database_not_allowed, role_not_allowed, token_not_validThe key can't use the database, its role is owner-level, or a URL's token is for another object, used already, or its key was revoked
404bucket_not_found, object_not_found, upload_not_foundMissing or hidden (the same answer, so a hidden object isn't revealed)
408request_timeoutThe body went idle
409conflictThe name is taken, or it changed meanwhile
409write_onceA write-once bucket refused the change
409referenced, delete_refused, retryA delete refused by a table that references the object; see Object references
409bucket_not_emptyA bucket holding objects can't be deleted
410upload_expiredThe resumable upload's 24 hours are up
413too_largeOver the bucket's file_size_limit, or a part over 100 MiB
415mime_not_allowedA type the bucket doesn't allow
416range_not_satisfiableA Range outside the object
503busy, unavailable, storage_unavailableToo many uploads in flight, or the database or storage isn't available right now; retry

On this page