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.

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 files table and queryable through the same db interface 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:

  1. Requests a presigned PUT URL from the backend
  2. Uploads the file directly to S3
  3. 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, and quality (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()

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 FileStorageError with code OFFLINE.

  • Name
    Download URL
    Description

    Requires network to fetch a fresh presigned URL. The useFileUrl hook 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 OFFLINE errors if unreachable.

  • Name
    File metadata queries
    Description

    If your app uses offline sync, the files table is synced to the local SQLite cache like any other table. You can query file metadata (names, sizes, folder paths) offline via db.selectFrom('files'). Only presigned URL generation and binary transfers need the network.

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-manipulator to compress before passing to upload().

  • 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 /docs applies to /docs/reports/2024 unless 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.com for DigitalOcean Spaces, or https://s3.us-east-1.amazonaws.com for 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

  1. Click Test Connection to verify the S3 credentials and bucket access.
  2. Once the test passes, enable the File Uploads toggle.
  3. Click Save to apply changes.

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.

Next pages

  1. Files API reference
  2. React hooks reference
  3. Offline-first overview
  4. Access control

Was this page helpful?