Blobs and Files
Primitive has two kinds of blob storage, matched to different lifetimes and access models:
- Document blobs — files attached to a specific document. Permissions follow the document.
- Blob buckets — general-purpose binary storage that isn't tied to a document. Each bucket has its own access preset and retention tier.
Use document blobs when the file's lifetime and access should track a document — an attachment, an image embedded in a record. Use buckets when you need file storage that doesn't map cleanly onto a document:
- Thumbnails, generated assets, and other computed artifacts
- User avatars
- Server-generated files (PDF reports, exported spreadsheets)
- Short-lived transfer files (time-boxed download links)
- Public assets (logos, brand images) that need to be served without auth
Document Blobs
Documents can carry binary files — images, PDFs, attachments. Each blob belongs to one document and is reached through that document's blob context; uploads, downloads, and listing all hang off it. The key idea: document blobs inherit the sharing of their document. Anyone who can read the document can read its blobs, anyone with write access can add or delete them, and when you share the document its files come along — there's no separate permission system to manage.
Uploading Files
const blobs = client.document(documentId).blobs();
const { blobId } = await blobs.upload(data, {
filename: "notes.txt",
contentType: "text/plain",
});let blobs = client.documents.blobs(documentId: documentId)
let result = try await blobs.upload(
data: data,
options: BlobUploadSourceOptions(filename: "notes.txt", contentType: "text/plain")
)
let blobId = result.blobIdDisplaying Images
const imageUrl = blobs.downloadUrl(blobId, { disposition: "inline" });let imageUrl = blobs.downloadUrl(blobId: blobId, disposition: .inline)<template>
<img :src="imageUrl" alt="User uploaded image" />
</template>The URL works directly as an <img> source or in a SwiftUI AsyncImage, and only for a signed-in user with access to the document.
Downloading Files
const blobs = client.document(documentId).blobs();
const list = await blobs.list();
const url = blobs.downloadUrl(blobId); // synchronous, authenticated
const bytes = await blobs.read(blobId, { as: "arrayBuffer" });let blobs = client.documents.blobs(documentId: documentId)
let list = try await blobs.list()
let url = blobs.downloadUrl(blobId: blobId) // synchronous, authenticated
let bytes = try await blobs.read(blobId: blobId)downloadUrl is synchronous and authenticated.
Listing and Managing Document Blobs
const blobs = client.document(documentId).blobs();
const { items, nextCursor } = await blobs.list({ limit: 50 });
const meta = await blobs.get(blobId);
await blobs.delete(blobId);let blobs = client.documents.blobs(documentId: documentId)
let list = try await blobs.list(limit: 50)
let meta = try await blobs.get(blobId: blobId)
_ = try await blobs.delete(blobId: blobId)Caching and Upload Queuing
Blob storage tolerates flaky connections:
- Uploads queue when the network drops and complete automatically when it returns
- Downloaded files cache locally, so repeat reads are instant
- Prefetch files proactively to warm the cache:
const blobs = client.document(documentId).blobs();
await blobs.prefetch([blobId1, blobId2], { concurrency: 4 });let blobs = client.documents.blobs(documentId: documentId)
await blobs.prefetch(blobIds: [blobId1, blobId2], concurrency: 4)Use the Blob Explorer in the dev tools overlay to browse, upload, and manage blobs during development.
Blob Buckets
Quick Start
1. Define a Bucket
# primitive/dev/blob-buckets/avatars.toml
[bucket]
key = "avatars"
name = "User avatars"
preset = "authenticated"
ttlTier = "permanent"Push it:
primitive config push --only blob-bucket/avatarsprimitive config create blob-bucket avatars scaffolds the file from the type's defaults if you'd rather not start from a blank one.
2. Upload
const { blobId } = await client.blobBuckets.upload("avatars", {
filename: "alice.jpg",
contentType: "image/jpeg",
data,
tags: ["profile"],
});let result = try await client.blobBuckets.upload(
bucketIdOrKey: "avatars",
data: data,
filename: "alice.jpg",
contentType: "image/jpeg",
tags: ["profile"]
)
let blobId = result.blobIdUploads take optional tags for organizing and filtering blobs within a bucket.
3. Read
// Signed URL (for <img> tags, etc.)
const { url } = await client.blobBuckets.getSignedUrl("avatars", blobId, 3600);
// Or download the bytes directly
const bytes = await client.blobBuckets.download("avatars", blobId);// Signed URL (for <img> tags, etc.)
let signed = try await client.blobBuckets.getSignedUrl(
bucketIdOrKey: "avatars", blobId: blobId, expiresInSeconds: 3600
)
let url = signed.url
// Or download the bytes directly
let bytes = try await client.blobBuckets.download(bucketIdOrKey: "avatars", blobId: blobId)Access Presets
A bucket's preset decides who can use its blobs. App admins and owners always have full access; the preset governs everyone else:
| Preset | Access for everyone else | Use case |
|---|---|---|
public | Anyone — including visitors with no account — can read and list. Only admins and owners write. | Logos, brand images, assets served without auth |
authenticated | Any signed-in member can read, write, list, delete, and share | User avatars, app-wide shared assets |
admin-only | No one but admins and owners | Internal artifacts, server-generated files you serve out selectively via signed URLs |
personal-uploads | Any member uploads; each member reads, deletes, and shares only their own blobs (the bucket isn't enumerable) | User file uploads where everyone keeps their own |
Pick the simplest preset that fits. A public bucket is the one case where reads don't require a signed-in user — an unauthenticated request can fetch its blobs directly.
Each preset governs blob operations at the granularity of read (download and metadata), write (upload), list (enumerate the bucket), delete, and share (mint a signed URL).
Custom access
When no preset fits, attach a rule set to the bucket by setting ruleSetId in the bucket's TOML. The rule set becomes the authority for member access: admins and owners are always allowed, and each operation (read, write, list, delete, share) is decided by a CEL rule. Two values make bucket rules expressive:
isAnonymous()— true when the caller has no account, so!isAnonymous()means "any signed-in member."record.blobCreatedBy— the id of the member who uploaded the blob, for owner-scoped rules likerecord.blobCreatedBy == user.userId.
See Access Control for the rule-set model. To change a bucket's preset or attached rule set later, edit its TOML and run primitive config push again, or change it at runtime from your app with updateBucket (admin/owner only). Setting a named preset clears any attached rule set:
// Switch the bucket to a different preset.
await client.blobBuckets.updateBucket("uploads", { preset: "admin-only" });
// Attach a custom rule set (makes the bucket `custom`); pass null to clear it.
await client.blobBuckets.updateBucket("uploads", { ruleSetId: "rule-set-id" });// Switch the bucket to a different preset.
_ = try await client.blobBuckets.updateBucket(
bucketIdOrKey: "uploads",
params: UpdateBlobBucketParams(preset: .adminOnly)
)
// Attach a custom rule set (makes the bucket `custom`); use .clear to remove it.
_ = try await client.blobBuckets.updateBucket(
bucketIdOrKey: "uploads",
params: UpdateBlobBucketParams(ruleSetId: .value("rule-set-id"))
)TTL Tiers
Each bucket has a TTL tier that governs how long blobs live before the storage layer deletes them. Pick the shortest tier that fits — short-lived blobs are cheaper and safer.
| Tier | Retention |
|---|---|
1d / 3d / 14d / 28d | Hours-to-weeks scratch storage: download links, transient uploads, session artifacts |
180d / 365d | Time-boxed user content and reports |
permanent | No TTL — avatars, brand assets, archives |
TTL is set at the bucket level — every blob in the bucket inherits it. To mix retention policies, create separate buckets.
Signed URLs
Buckets don't expose raw storage URLs. Reads go through either the Primitive API (authenticated) or a time-limited signed URL you generate on demand with client.blobBuckets.getSignedUrl(...) (shown in Read above).
Signed URLs:
- Are safe to put in
<img>tags or hand to clients that can't attach auth headers - Expire after the time you specify — from 30 seconds up to 24 hours (default 5 minutes)
- Respect the bucket's preset at generation time — if the user can't share, the call fails
- Don't require the recipient to be authenticated during the valid window
Use a short expiry for user-facing URLs and regenerate as needed.
Listing and Managing Bucket Blobs
// List blobs in the bucket
const { items, nextCursor } = await client.blobBuckets.list("avatars", { limit: 50 });
// One blob's metadata
const meta = await client.blobBuckets.getMetadata("avatars", blobId);
// Delete a blob
await client.blobBuckets.delete("avatars", blobId);
// Delete a batch of blobs (up to 500 ids) in one call
const batchResult = await client.blobBuckets.delete("avatars", expiredIds);
// batchResult: { deleted, blobIds, bucketId }// List blobs in the bucket
let page = try await client.blobBuckets.list(bucketIdOrKey: "avatars", limit: 50)
let items = page.items
let nextCursor = page.nextCursor
// One blob's metadata
let meta = try await client.blobBuckets.getMetadata(bucketIdOrKey: "avatars", blobId: blobId)
// Delete a blob
_ = try await client.blobBuckets.delete(bucketIdOrKey: "avatars", blobId: blobId)
// Delete a batch of blobs (up to 500 ids) in one call
let batchResult = try await client.blobBuckets.delete(bucketIdOrKey: "avatars", blobIds: expiredIds)
// batchResult: BatchBlobDeleteResult { deleted, blobIds, bucketId }To delete a set of blobs in one call, pass delete an array of ids (up to 500) instead of a single id — shown above as batchResult. The whole batch is screened against the bucket's delete rule before anything is removed, so it succeeds or fails as a unit, and the result is { deleted, blobIds, bucketId } where deleted counts the ids processed.
An empty array is a valid no-op, so a computed list that resolves to nothing doesn't throw.
Using Buckets from a Server Function
A server function writes, reads, signs URLs for, and deletes bucket blobs through ctx.api.blobBuckets, on the app's own authority — the function's own access gate is the authorization point, not the bucket's preset or rule set, which governs calls from your app's clients.
A blob a task run reads as input must outlive the run. A step that is retried or replayed runs against the same input, so it fetches the same blob again — and deleting that blob between attempts fails the step, and the run, with a not-found error. Don't delete a blob from a cancel or cleanup path while a run that references it can still retry; give the bucket a TTL tier and let expiry clean it up.
CLI Reference
# List buckets
primitive blob-buckets list
# Inspect one
primitive blob-buckets get avatars
# List blobs in a bucket
primitive blob-buckets list-blobs avatars
# Inspect one blob's metadata (size, content type, checksum) without downloading it
primitive blob-buckets head avatars <blobId>
# Upload a file from your machine
primitive blob-buckets upload avatars ./alice.jpg --content-type image/jpeg --tags profile
# Generate a signed URL
primitive blob-buckets signed-url avatars <blobId> --expires 3600
# Delete one or more blobs (multiple ids delete as one batch;
# --batch forces the batch endpoint even for a single id)
primitive blob-buckets delete-blob avatars <blobId> [<blobId>...]A bucket's own settings — name, description, preset, ruleSetId, ttlTier — are configuration: change them in blob-buckets/avatars.toml and push.
primitive config set blob-bucket/avatars bucket.preset=admin-only
primitive config push --only blob-bucket/avatarsDelete a bucket by removing its file and running primitive config push --prune.
Buckets vs. Document Blobs
Use document blobs when the file's lifetime matches a document's, access should follow document permissions, and the file is conceptually an attachment to document content.
Use buckets when any of these apply:
- Multiple documents (or no documents) reference the file
- You need public or admin-curated access
- You want server-governed TTL
- You need signed URLs for external sharing
Limits
- Document blob size — 10 MB per blob.
- Bucket object size — 100 MB per blob.
- Batch delete — 500 ids per call, from the client, the CLI, or a server function.
- Signed URL expiry — see Signed URLs for the range and default.
Next Steps
- Working with Documents — Local-first collaborative storage that document blobs attach to
- Working with Databases — If your records need file attachments with structured metadata, store the
blobIdin a database and use a bucket with the matching access policy - Server Functions — Server-side automation that reads and writes blobs