dabadataDocs
Concepts / File storage

Concepts

File storage

dabadata file storage keeps files for your app's users - avatars, attachments, documents - with the same rules, trace and live updates as rows. Declare a bucket, and the client uploads straight to object storage (Cloudflare R2) with a short-lived signed URL; the bytes never pass through the API server, and every file is a row you can query and subscribe to.

Buckets

Add a storage block to the definition. Each bucket has rules for read, write (upload) and delete, and may limit the size and type of its files:

json
"storage": {
  "buckets": {
    "avatars": {
      "read": "anyone",
      "write": { "owner": "owner" },
      "delete": { "owner": "owner" },
      "maxSize": "5MB",
      "types": ["image/png", "image/jpeg", "image/webp"]
    },
    "docs": {
      "read": { "role": ["admin", "member"] },
      "write": "authenticated",
      "delete": { "role": "admin" }
    }
  }
}
  • Bucket names follow table names (lower case, digits, _; at most 35 characters).
  • read is required, even if it is "nobody"; a missing write or delete is "nobody" (only the secret key and the console may then upload or delete).
  • maxSize: "500KB", "5MB", "1GB" (binary units) or a number of bytes; at most the plan's largest file.
  • types: MIME types, with wildcards such as "image/*". Left out, any type is taken.
  • attach: "required" deletes files no file column holds 24 hours after their upload; "optional" (the default) keeps them.

Every bucket is also a read-only table, files_<bucket>, with the columns id, name, size, type, owner, created_at and _seq. Read it with GET /v1/db/files_avatars, filter it with where, subscribe to it like any table. Writes go only through the storage routes: /v1/mutate on it answers 400 files_avatars is managed by storage.

Rules

Bucket rules use the rule grammar of tables. owner refers to the file's owner, which is the id of the user who uploaded it, so { "owner": "owner" } reads "the uploader". The rules decide each operation:

RuleDecidesTrace op
readlisting files, downloads, live updates of files_<bucket>read, download
writestarting and completing an uploadupload
deletedeleting a filedelete

Every upload, download and delete writes a trace row with the rule's explanation, for example upload files_avatars: allowed by owner(owner): values.owner "u1" == user "u1" or download files_docs: denied, role "viewer" not in ["admin","member"]. The secret key and the console bypass the rules, as for rows.

Uploads

An upload is three steps; db.storage.upload does all three:

  1. POST /v1/storage/<bucket>/uploads with { name, size, type }. The server checks the write rule, the bucket's types and size and the plan, and answers a pending file and a signed upload URL, valid 10 minutes, with the exact size and type signed into it.
  2. PUT the bytes to that URL with the Content-Type it names. Another size or type is refused by the store.
  3. POST /v1/storage/<bucket>/uploads/<id>/complete. The server checks the stored object (size and type), and the file becomes a row of files_<bucket>: live subscribers get the insert, the trace gets the upload.
bash
curl -X POST https://dabadata.com/v1/storage/avatars/uploads \
  -H "X-Api-Key: pk_live_EXAMPLEexample0000000000" -H "Authorization: Bearer $USER_TOKEN" \
  -H "Content-Type: application/json" -d '{"name":"me.png","size":48213,"type":"image/png"}'
# -> { "file": { "id": "...", "status": "pending", ... },
#      "upload": { "url": "https://...r2.cloudflarestorage.com/...", "method": "PUT",
#                  "headers": { "Content-Type": "image/png" }, "expiresAt": "..." } }
curl -X PUT "$UPLOAD_URL" -H "Content-Type: image/png" --data-binary @me.png
curl -X POST https://dabadata.com/v1/storage/avatars/uploads/$FILE_ID/complete \
  -H "X-Api-Key: pk_live_EXAMPLEexample0000000000" -H "Authorization: Bearer $USER_TOKEN"

Only the uploader (or the secret key) may complete an upload. Uploads never completed are removed after an hour. Your object storage bucket's CORS settings must allow PUT from your app's origin; on the hosted service they do.

Downloads

GET /v1/storage/<bucket>/<id> checks the read rule and redirects (302) to a signed download URL valid 5 minutes. Add ?json=1 to get { url, expiresAt, expiresIn } instead (expiresIn in seconds), for a link you hand to the browser yourself.

Files in a bucket whose read rule is exactly "anyone" also have a stable public URL, fine for <img src> and caches:

text
https://dabadata.com/files/<environment>/<bucket>/<file id>/<file name>

It answers a cacheable redirect (30 minutes) to a signed URL, so the bytes come from object storage, never through the API. Downloads are free and do not count toward your egress.

Limits

FreeStarterProScale
File storage2 GB20 GB100 GB500 GB
Largest file50 MB500 MB5 GB5 GB
Above the allowancerefused$0.02 / GB-month$0.02 / GB-month$0.02 / GB-month

The allowance is for the whole workspace and counts uploads still pending. An upload that would go over it is refused with 403 { "error": "plan_limit", "limit": "files" }; a file larger than the plan allows with 403 limit: "maxFileBytes"; a file larger than the bucket's maxSize, or of a type it does not take, with 400. Files already stored stay readable. File bytes are measured nightly and shown on the console's Usage page. See Plans and limits.

Client API

js
const file = await db.storage.upload("avatars", input.files[0], {
  name: "me.png",                                  // default: the File's name
  onProgress: ({ loaded, total }) => bar.value = loaded / total,
});
const src = await db.storage.url("avatars", file.id);   // public URL, or a 5-minute signed one
const { url, expiresAt } = await db.storage.signedUrl("docs", id);
await db.storage.remove("avatars", file.id);
const mine = await db.storage.list("avatars", { where: { owner: userId }, limit: 20 });
db.subscribe("files_avatars", {}, { onChange: (c) => render(c) });

Private files on pages that stay open

A signed URL lives 5 minutes. A page open longer - a chat, a profile list, lazy images below the fold - needs a fresh one when the browser loads the file again. Two helpers do it:

js
// An <img> or <video>: sets src; when loading fails because the link expired, sets a fresh one.
const stop = db.storage.bindImage(img, "photos", row.photo, { onError: (e) => img.alt = "Photo unavailable" });

// Anything else (a download link, a CSS background): onUrl now, and again before each URL expires.
const stopLink = db.storage.watchUrl("docs", id, (url) => (link.href = url));

stop(); stopLink();   // when the element goes away
  • bindImage never swaps the source of an image that already loaded (that would download it again). It takes one fresh URL per load; a second failure before the image loads is a broken file, and goes to onError instead of a retry loop.
  • watchUrl renews at 80% of each URL's life, by the server's expiresIn, so a device with a wrong clock still renews in time. When a background tab comes back past that time, it renews at once. A failed renewal calls onError and retries after 5 s, 15 s, then every 60 s; a 403 (a rule now refuses) or a 404 (the file is gone) stops it.
  • A bucket anyone may read has a stable public URL: both helpers set it once and never renew.

A refused call rejects with a DabadataError whose explanation is the rule's, as for rows. A failed PUT rejects with code: "upload_failed". On the MCP server, each bucket has a files_<bucket>_list tool and there is a file_url tool; uploads stay in your app.

File columns

A file column holds the id of a file of one bucket - a profile's photo, a message's attachment - and keeps the two in step:

json
"profiles": {
  "columns": {
    "name": { "type": "text" },
    "owner": { "type": "userId" },
    "photo": { "type": "file", "bucket": "avatars", "optional": true }
  },
  "rules": { "read": "anyone", "create": "authenticated", "update": { "owner": "owner" } }
}
  • A write names a real file. Setting the column (insert, update, a transaction, an action) to a file that is not a ready file of that bucket answers 400 column "photo": no ready file "..." in bucket avatars (upload and complete it first). An end user may attach only files they uploaded; the secret key and the console may attach any. Anonymous uploads have no uploader, so any anonymous caller with the id may attach one - give write to signed-in users when that matters.
  • A file let go of is deleted. A new value (or null) deletes the file it replaces, and deleting the row - also through a cascade - deletes its files, right after the write commits. The cleanup finishes before the write answers, so when your app gets the response the old file is gone from storage and its links no longer serve it (a browser that showed it may still hold a cached copy). A file another row still holds stays. "onReplace": "keep" and "onDelete": "keep" keep them instead. If the object store does not answer then, the nightly job finishes it.
  • Uploads nobody attached. A bucket with "attach": "required" deletes a ready file that no file column holds 24 hours after its upload completed (nightly). Without it, unattached files stay until deleted.
  • Taking a column out of the definition keeps its files. Its rows stay in the database (an apply never drops), and so do the files they hold: each apply marks file columns in the database, and the mark follows a rename. Drop the column with a migrate when you want it gone: its files are deleted with it, unless another column holds them.

The client does the upload and the row update in one call:

js
await db.storage.upload("avatars", input.files[0], {
  attachTo: { table: "profiles", id: profile.id, column: "photo" },   // then update profiles.photo
});

The update follows the table's rules; when it is refused the upload stays and the error is thrown with the file as error.file, so you can retry the attach or remove it (in an attach: "required" bucket it is removed after 24 hours). In filters, sorts and rules a file column is text.

Example: avatar upload

html
<input type="file" id="pick" accept="image/png,image/jpeg,image/webp">
<progress id="bar" max="1" value="0"></progress>
<img id="avatar" alt="" width="96" height="96">
<script type="module">
  import { createClient } from "https://dabadata.com/client/dist/dabadata.js";
  const db = createClient({ url: "https://dabadata.com", key: "pk_live_EXAMPLEexample0000000000" });
  // Signed in with db.auth (or a token from your backend): uploads are owned by this user.
  pick.onchange = async () => {
    const file = await db.storage.upload("avatars", pick.files[0], {
      onProgress: (p) => (bar.value = p.loaded / p.total),
    });
    avatar.src = await db.storage.url("avatars", file.id);
  };
</script>

With the avatars bucket above, anyone can see the picture, only its uploader can replace or delete it, and the bucket takes PNG, JPEG and WebP up to 5 MB.

Resize before upload

A phone photo is often 3-8 MB. Make a full image and a thumbnail in the browser, upload both, and keep each in its own file column:

json
"storage": {
  "buckets": {
    "photos": {
      "read": "anyone",
      "write": "authenticated",
      "delete": { "owner": "owner" },
      "maxSize": "2MB",
      "types": ["image/webp", "image/jpeg"],
      "attach": "required"
    }
  }
},
"tables": {
  "profiles": {
    "columns": {
      "name": { "type": "text" },
      "owner": { "type": "userId" },
      "photo": { "type": "file", "bucket": "photos", "optional": true },
      "photo_thumb": { "type": "file", "bucket": "photos", "optional": true }
    },
    "rules": { "read": "anyone", "create": "authenticated", "update": { "owner": "owner" } }
  }
}
js
// A copy of an image at most `max` pixels on its longer side: WebP, or JPEG where the browser cannot encode WebP.
async function resized(file, max, quality = 0.82) {
  const img = await createImageBitmap(file, { imageOrientation: "from-image" });   // upright, as the camera held it
  const scale = Math.min(1, max / Math.max(img.width, img.height));
  const canvas = document.createElement("canvas");
  canvas.width = Math.round(img.width * scale);
  canvas.height = Math.round(img.height * scale);
  canvas.getContext("2d").drawImage(img, 0, 0, canvas.width, canvas.height);
  img.close();
  const encode = (type) => new Promise((ok) => canvas.toBlob(ok, type, quality));
  const webp = await encode("image/webp");
  return webp?.type === "image/webp" ? webp : encode("image/jpeg");
}

pick.onchange = async () => {
  const original = pick.files[0];
  const [full, thumb] = await Promise.all([resized(original, 1600), resized(original, 320, 0.75)]);
  const ext = (b) => (b.type === "image/webp" ? "webp" : "jpg");
  await db.storage.upload("photos", full, {
    name: `photo.${ext(full)}`,
    attachTo: { table: "profiles", id: profile.id, column: "photo" },
  });
  await db.storage.upload("photos", thumb, {
    name: `photo-thumb.${ext(thumb)}`,
    attachTo: { table: "profiles", id: profile.id, column: "photo_thumb" },
  });
};
  • Show photo_thumb in lists and photo on the profile page: await db.storage.url("photos", row.photo_thumb), or db.storage.bindImage(img, "photos", row.photo_thumb) when the bucket is private and the page stays open (renewing URLs).
  • A new upload replaces the old files: each column deletes the file it held (onReplace), so a profile keeps two files, not a growing pile.
  • Both files count toward your file storage; a 1600 px WebP is usually 150-400 KB and a 320 px thumbnail 10-30 KB.
  • With "attach": "required", an upload whose attach was refused is removed after 24 hours.
  • The canvas copy carries no camera data, so the photo's GPS location is not uploaded with it.
dabadata documentation, written against API contract v2.