File storage
FFDB includes built-in file storage powered by any S3-compatible provider (AWS S3, DigitalOcean Spaces, MinIO, etc.). Files upload directly from the client to your bucket via presigned URLs — they never pass through backend memory.
File storage must be enabled by an admin before use. See Admin setup for the full configuration checklist.
How it works
- Name
Presigned URL flow- Description
The client requests a presigned upload URL from the backend, uploads the file directly to S3, then confirms the upload. No file data passes through the backend.
- Name
Cross-platform- Description
Works in browsers, React Native, Electron, and Node.js. The client uses standard HTTP PUT/GET requests — no S3 SDK needed on the client side.
- Name
Metadata in SQLite- Description
File metadata is stored in the
filestable and queryable through the samedbinterface you use for other data.
Upload a file
const { files } = await createClient<Database>({
config: { apiUrl: import.meta.env.VITE_FFDB_API_URL },
})
const record = await files.upload(fileInput, {
folderPath: '/avatars',
visibility: 'private',
privacy: 'org',
})
console.log(record.id, record.name, record.size_bytes)
The upload method accepts a browser File, a Blob, or a React Native-style { uri, type, name } object. It handles the full three-step flow internally:
- Requests a presigned PUT URL from the backend
- Uploads the file directly to S3
- Confirms the upload and returns the final
FileRecord
Upload options
await files.upload(file, {
name: 'profile.jpg',
folderPath: '/users/avatars',
visibility: 'public',
privacy: 'org',
description: 'User profile photo',
compress: true,
compressOptions: { maxWidth: 1200, maxHeight: 1200, quality: 0.8 },
})
- Name
folderPath- Type
- string
- Description
Organize files into folders. Defaults to
'/'.
- Name
visibility- Type
- 'public' | 'private'
- Description
Public files get a direct URL with no expiry. Private files require a presigned download URL. Defaults to
'private'.
- Name
privacy- Type
- 'private' | 'org' | 'team'
- Description
Controls who can access the file.
'private'means only the uploader,'org'means all org members,'team'means team members. Defaults to'org'.
- Name
compress- Type
- boolean
- Description
Enable client-side image compression before upload. Browser only — skipped in Node.js and React Native.
- Name
compressOptions- Type
- CompressOptions
- Description
Configure compression with
maxWidth,maxHeight, andquality(0-1).
- Name
maxSizeBytes- Type
- number
- Description
Override the global max file size for this upload.
- Name
allowedMimeTypes- Type
- string[]
- Description
Override the global allowed MIME types for this upload.
Get a download URL
const { url, expiresAt, public: isPublic } = await files.getUrl(record.id)
Public files return a direct, non-expiring URL. Private files return a presigned URL that expires after the configured TTL (default: 1 hour).
List files
const { files: fileList, total } = await files.list('/documents')
Returns files the current user has permission to see in the given folder. Omit the folder argument to list all accessible files.
Update file metadata
await files.update(record.id, {
name: 'renamed-photo.jpg',
description: 'Updated description',
folder_path: '/photos/2024',
})
Delete a file
await files.delete(record.id)
Soft-deletes the file and removes the S3 object. Deleted files are cleaned up permanently by the maintenance worker.
Query files with SQL
File metadata lives in the files table. You can query it with the same db builder or sql helper used for other tables:
const photos = await db
.selectFrom('files')
.select(['id', 'name', 'size_bytes', 'mime_type'])
.where('folder_path', '=', '/photos')
.where('status', '=', 'confirmed')
.orderBy('created_at desc')
.execute()
The files table is subject to normal row-level security policies. The file_folder_permissions table is admin-only and not queryable from client code.
React hooks
The SDK provides React hooks for file operations that handle loading state, errors, and automatic presigned URL refresh. Import them from ffdb-client/react.
useFileUrl
Fetches a presigned download URL for a file and automatically refreshes it before it expires. Bind the returned url to an <img> or <a> and it stays valid as long as the component is mounted.
import { useFileUrl } from 'ffdb-client/react'
function Avatar({ fileId }: { fileId: string }) {
const { url, isLoading, error } = useFileUrl(fileId)
if (isLoading) return <Skeleton />
if (error) return <span>Failed to load</span>
return <img src={url!} alt="avatar" />
}
By default, the hook refreshes the URL 60 seconds before it expires. You can adjust this:
const { url } = useFileUrl(fileId, {
refreshBeforeExpiryMs: 120_000, // refresh 2 min before expiry
})
Public files are fetched once and never refreshed (they don't expire).
useFileList
Lists files in a folder with automatic refetch on window focus.
import { useFileList } from 'ffdb-client/react'
function DocumentList() {
const { files, total, isLoading, error, refetch } = useFileList('/documents')
if (isLoading) return <Spinner />
return (
<ul>
{files.map(file => (
<li key={file.id}>{file.name} ({file.size_bytes} bytes)</li>
))}
</ul>
)
}
Options:
useFileList('/photos', {
enabled: isAuthenticated,
refetchOnFocus: true, // default: true
refetchInterval: 30_000, // poll every 30s
})
useUpload
Wraps files.upload with isUploading, error, and lastUpload state for UI feedback.
import { useUpload } from 'ffdb-client/react'
function UploadButton() {
const { upload, isUploading, error, lastUpload } = useUpload()
const onFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
try {
const record = await upload(file, {
folderPath: '/uploads',
compress: true,
})
console.log('Uploaded:', record.id)
} catch {
// error is also available via the hook's error state
}
}
return (
<div>
<input type="file" onChange={onFileChange} disabled={isUploading} />
{isUploading && <span>Uploading...</span>}
{error && <span>Error: {error.message}</span>}
{lastUpload && <span>Last upload: {lastUpload.name}</span>}
</div>
)
}
useFiles
Returns the raw files namespace from the client. Mirrors the useDB() / useAuth() pattern for imperative access.
import { useFiles } from 'ffdb-client/react'
function DeleteButton({ fileId }: { fileId: string }) {
const files = useFiles()
return (
<button onClick={() => files.delete(fileId)}>
Delete
</button>
)
}
Full example: file gallery with fresh URLs
import { useFileList, useFileUrl, useUpload } from 'ffdb-client/react'
function FileGallery() {
const { files, refetch } = useFileList('/photos')
const { upload, isUploading } = useUpload()
const onUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
await upload(file, { folderPath: '/photos', compress: true })
refetch()
}
return (
<div>
<input type="file" accept="image/*" onChange={onUpload} disabled={isUploading} />
<div className="grid grid-cols-3 gap-2">
{files.map(file => (
<GalleryImage key={file.id} fileId={file.id} name={file.name} />
))}
</div>
</div>
)
}
function GalleryImage({ fileId, name }: { fileId: string; name: string }) {
const { url, isLoading } = useFileUrl(fileId)
if (isLoading || !url) return <Skeleton />
return <img src={url} alt={name} />
}
Offline behavior
File storage requires a network connection — binary file data cannot be queued or synced through the offline mutation system. Here is how each operation behaves:
- Name
Upload- Description
Requires network. The three-step presigned URL flow (request → PUT to S3 → confirm) cannot be deferred offline. If the client is offline, the upload throws a
FileStorageErrorwith codeOFFLINE.
- Name
Download URL- Description
Requires network to fetch a fresh presigned URL. The
useFileUrlhook keeps URLs fresh while mounted — if the network drops temporarily, the current URL remains valid until its TTL expires.
- Name
List / Update / Delete- Description
Require network. These operations call the backend API and will throw
OFFLINEerrors if unreachable.
- Name
File metadata queries- Description
If your app uses offline sync, the
filestable is synced to the local SQLite cache like any other table. You can query file metadata (names, sizes, folder paths) offline viadb.selectFrom('files'). Only presigned URL generation and binary transfers need the network.
If you are using offline sync and want file metadata available offline, make sure 'files' is included in your sync table list (or not excluded via skipTables).
Recommended pattern for offline-aware apps
Check network status before attempting file operations:
import { useFFDB, useUpload } from 'ffdb-client/react'
function OfflineAwareUpload() {
const { sync } = useFFDB()
const { upload, isUploading, error } = useUpload()
const isOnline = sync?.status.isOnline ?? true
return (
<div>
<input
type="file"
onChange={e => {
const file = e.target.files?.[0]
if (file) upload(file, { folderPath: '/uploads' })
}}
disabled={isUploading || !isOnline}
/>
{!isOnline && <p>File uploads are unavailable while offline.</p>}
{error && <p>{error.message}</p>}
</div>
)
}
Image compression
In browser environments, you can compress images before upload to reduce file size and upload time:
await files.upload(largePhoto, {
compress: true,
compressOptions: {
maxWidth: 1920,
maxHeight: 1080,
quality: 0.85,
},
})
- Name
Browser- Description
Uses the Canvas API for resizing and recompression. PNG files stay PNG; other image formats are converted to JPEG.
- Name
React Native- Description
Compression is skipped. Use a library like
expo-image-manipulatorto compress before passing toupload().
- Name
Node.js- Description
Compression is skipped. Pre-process images with sharp or similar before uploading.
Permission model
File access is controlled by two layers that both must pass:
- Name
File privacy- Description
Set per-file during upload.
'private'restricts access to the uploader.'org'allows all org members.'team'allows team members.
- Name
Folder permissions- Description
Admins configure CRUD permissions on folder paths. Permissions inherit up the path hierarchy — a rule on
/docsapplies to/docs/reports/2024unless overridden by a more specific rule.
Admin users bypass all permission checks.
Admin setup
Before files can be uploaded, an admin must complete the following setup in the admin dashboard.
1. Configure S3 credentials
Navigate to Settings > Files and fill in:
- Name
Endpoint URL- Type
- required
- Description
Your S3-compatible endpoint, e.g.
https://nyc3.digitaloceanspaces.comfor DigitalOcean Spaces, orhttps://s3.us-east-1.amazonaws.comfor AWS.
- Name
Region- Type
- required
- Description
The region code, e.g.
nyc3,us-east-1.
- Name
Bucket Name- Type
- required
- Description
The name of your S3 bucket. It must already exist — FFDB does not create buckets.
- Name
Access Key ID- Type
- required
- Description
An IAM or Spaces access key with read/write permissions on the bucket.
- Name
Secret Access Key- Type
- required
- Description
The corresponding secret key. This is stored encrypted and never exposed in the API.
2. Set upload limits
- Name
Max File Size- Description
Maximum size per upload in bytes. Defaults to 10 MB (10485760 bytes). Can be overridden per-upload from the client SDK.
- Name
Allowed MIME Types- Description
One MIME type per line. Use wildcards like
image/*for all images. Defaults to common types (images, PDFs, text).
- Name
Presigned URL TTL- Description
How long presigned upload and download URLs remain valid, in seconds. Defaults to 3600 (1 hour).
3. Test and enable
- Click Test Connection to verify the S3 credentials and bucket access.
- Once the test passes, enable the File Uploads toggle.
- Click Save to apply changes.
Only enable file uploads after the connection test passes. If S3 is misconfigured, uploads will fail with an S3_ERROR.
4. Configure bucket access policy
For public file visibility to work, configure your bucket policy to allow public reads on the public/ prefix:
DigitalOcean Spaces — set a CORS policy allowing GET from your app's origin, and set the public/ folder to public access via the Spaces settings panel.
AWS S3 — add a bucket policy granting s3:GetObject on arn:aws:s3:::your-bucket/public/* to *.
Private files always use presigned URLs and do not need public bucket access.
Error handling
All file operations throw a FileStorageError with a descriptive code when something goes wrong. You can import it to handle specific cases:
import { FileStorageError } from 'ffdb-client'
try {
await files.upload(file)
} catch (err) {
if (err instanceof FileStorageError) {
switch (err.code) {
case 'FILE_UPLOAD_DISABLED':
// Admin hasn't enabled file storage yet
break
case 'S3_ERROR':
// S3 credentials or bucket misconfigured
break
case 'OFFLINE':
// No network connection
break
case 'FILE_TOO_LARGE':
// File exceeds max size
break
case 'MIME_TYPE_NOT_ALLOWED':
// File type not in the allowed list
break
case 'PERMISSION_DENIED':
// User lacks folder permission
break
case 'STORAGE_LIMIT_EXCEEDED':
// Org storage quota full
break
case 'S3_UPLOAD_FAILED':
// Presigned URL expired or S3 rejected the upload
break
}
}
}
- Name
FILE_UPLOAD_DISABLED- Description
File storage is not enabled. An admin needs to configure S3 and enable uploads in Settings.
- Name
S3_ERROR- Description
The backend could not communicate with S3. Check endpoint, credentials, and bucket configuration.
- Name
OFFLINE- Description
The client has no network connection. File operations require connectivity.
- Name
FILE_TOO_LARGE- Description
The file exceeds the configured maximum size (or the per-upload override).
- Name
MIME_TYPE_NOT_ALLOWED- Description
The file's MIME type is not in the allowed list.
- Name
PERMISSION_DENIED- Description
The user does not have the required folder permission for this operation.
- Name
STORAGE_LIMIT_EXCEEDED- Description
The org has reached its storage quota. Free up space or upgrade the plan.
- Name
S3_UPLOAD_FAILED- Description
The direct S3 upload failed. The presigned URL may have expired — retry the upload.
Storage and billing
File storage counts toward your org's total storage usage. Presigned URL generation (both upload and download) counts as read/write operations for billing purposes. The maintenance worker periodically cleans up stale pending uploads and permanently removes soft-deleted files.