Skip to main content
The Files API handles file uploads and downloads for records with file columns, providing seamless integration with object storage backends. Base Path: /api/records/v1

Upload Files with Records

Files are uploaded as part of record creation or updates using multipart form data.

Create Record with File

POST /api/records/v1/{name}
curl -X POST https://your-instance.com/api/records/v1/profiles \
  -H "Authorization: Bearer YOUR_AUTH_TOKEN" \
  -F "name=John Doe" \
  -F "bio=Software Developer" \
  -F "avatar=@/path/to/avatar.jpg"

Path Parameters

name
string
required
Record API name

Request Format

Use multipart/form-data content type with both data fields and file fields:
curl -X POST https://your-instance.com/api/records/v1/documents \
  -H "Authorization: Bearer YOUR_AUTH_TOKEN" \
  -F "title=My Document" \
  -F "description=Important file" \
  -F "file=@/path/to/document.pdf" \
  -F "file=@/path/to/another.pdf"

File Column Types

TrailBase supports two file column types:
  1. Single File (FileUpload): One file per field
  2. Multiple Files (FileUploads): Array of files per field

Response

{
  "ids": ["abc123def456"]
}
The file metadata is automatically stored in the record’s file column.

Update Record with File

PATCH /api/records/v1/{name}/{record}
curl -X PATCH https://your-instance.com/api/records/v1/profiles/abc123 \
  -H "Authorization: Bearer YOUR_AUTH_TOKEN" \
  -F "name=John Doe Updated" \
  -F "avatar=@/path/to/new-avatar.jpg"

File Replacement

When updating a file column:
  1. Old file is marked for deletion
  2. New file is uploaded
  3. Record is updated with new file metadata
  4. Old file is deleted asynchronously
If the update fails, newly uploaded files are automatically cleaned up to prevent orphaned files in storage.

Download Single File

Download a file from a single-file column.
GET /api/records/v1/{name}/{record}/file/{column_name}
curl -X GET https://your-instance.com/api/records/v1/profiles/abc123/file/avatar \
  -H "Authorization: Bearer YOUR_AUTH_TOKEN" \
  -o avatar.jpg

Path Parameters

name
string
required
Record API name
record
string
required
Record ID
column_name
string
required
Name of the file column

Response

Returns the file content with appropriate headers:
Content-Type: image/jpeg
Content-Disposition: attachment

Download File from Array

Download a specific file from a multi-file column.
GET /api/records/v1/{name}/{record}/files/{column_name}/{file_name}
curl -X GET https://your-instance.com/api/records/v1/documents/abc123/files/attachments/document.pdf \
  -H "Authorization: Bearer YOUR_AUTH_TOKEN" \
  -o document.pdf

Path Parameters

name
string
required
Record API name
record
string
required
Record ID
column_name
string
required
Name of the file array column
file_name
string
required
Original filename from the file metadata

File Metadata

When you create a record with files, the file column stores metadata:
{
  "id": "abc123",
  "title": "My Document",
  "attachments": [
    {
      "name": "document.pdf",
      "size": 1024000,
      "content_type": "application/pdf",
      "objectstore_id": "unique-storage-id"
    },
    {
      "name": "image.jpg",
      "size": 512000,
      "content_type": "image/jpeg",
      "objectstore_id": "another-storage-id"
    }
  ]
}

File Column Schema

Single File Column

CREATE TABLE profiles (
  id BLOB PRIMARY KEY,
  name TEXT NOT NULL,
  avatar BLOB CHECK(is_file_upload(avatar))
);

Multiple Files Column

CREATE TABLE documents (
  id BLOB PRIMARY KEY,
  title TEXT NOT NULL,
  attachments BLOB CHECK(is_file_uploads(attachments))
);

Storage Configuration

TrailBase supports multiple storage backends:

Local Filesystem

[storage]
type = "local"
path = "./data/files"

Amazon S3

[storage]
type = "s3"
bucket = "my-bucket"
region = "us-west-2"
access_key = "ACCESS_KEY"
secret_key = "SECRET_KEY"

Google Cloud Storage

[storage]
type = "gcs"
bucket = "my-bucket"
service_account_key = "/path/to/key.json"

Azure Blob Storage

[storage]
type = "azure"
account = "myaccount"
container = "my-container"
access_key = "ACCESS_KEY"

File Upload Constraints

File Size Limits

Configure maximum file size in your server settings:
[server]
max_upload_size = "10MB"  # Default limit

Allowed File Types

Implement CHECK constraints or access rules to restrict file types:
CREATE TABLE profiles (
  id BLOB PRIMARY KEY,
  avatar BLOB CHECK(
    is_file_upload(avatar) AND 
    json_extract(avatar, '$.content_type') IN ('image/jpeg', 'image/png', 'image/webp')
  )
);

Access Control

File downloads respect record-level access control:
-- Users can only download their own files
read_access_rule: "_ROW_.owner = _USER_.id"
Example:
# Fails: User doesn't own this record
curl -X GET https://your-instance.com/api/records/v1/profiles/abc123/file/avatar \
  -H "Authorization: Bearer OTHER_USER_TOKEN"
# Response: 403 Forbidden

# Success: User owns the record
curl -X GET https://your-instance.com/api/records/v1/profiles/abc123/file/avatar \
  -H "Authorization: Bearer OWNER_TOKEN"
# Response: 200 OK (file content)

File Lifecycle

Upload Flow

  1. Client sends multipart request with file(s)
  2. TrailBase validates file metadata
  3. File uploaded to object storage
  4. Record created/updated with file metadata
  5. On success, file is committed
  6. On failure, file is cleaned up

Delete Flow

  1. Record deleted (or file column updated)
  2. File marked for deletion in _file_deletions table
  3. Asynchronous cleanup process deletes file from storage
  4. Deletion retried up to 10 times on failure
  5. Abandoned after 10 failed attempts (logged as error)

Orphaned File Prevention

TrailBase uses transactional file management:
  • Files uploaded during failed operations are cleaned up
  • Database triggers track files for deletion
  • Background jobs handle async file cleanup
  • Failed deletions are retried automatically

JavaScript/TypeScript Client

Upload File

const formData = new FormData();
formData.append('title', 'My Document');
formData.append('file', fileInput.files[0]);

const response = await fetch('https://your-instance.com/api/records/v1/documents', {
  method: 'POST',
  headers: {
    'Authorization': `Bearer ${authToken}`
  },
  body: formData
});

const result = await response.json();
console.log('Uploaded record ID:', result.ids[0]);

Upload Multiple Files

const formData = new FormData();
formData.append('title', 'Multiple Attachments');

// Append multiple files to the same field
for (const file of fileInput.files) {
  formData.append('attachments', file);
}

const response = await fetch('https://your-instance.com/api/records/v1/documents', {
  method: 'POST',
  headers: {
    'Authorization': `Bearer ${authToken}`
  },
  body: formData
});

Download File

const response = await fetch(
  'https://your-instance.com/api/records/v1/profiles/abc123/file/avatar',
  {
    headers: {
      'Authorization': `Bearer ${authToken}`
    }
  }
);

if (response.ok) {
  const blob = await response.blob();
  const url = URL.createObjectURL(blob);
  
  // Display in img tag
  document.querySelector('#avatar').src = url;
  
  // Or trigger download
  const a = document.createElement('a');
  a.href = url;
  a.download = 'avatar.jpg';
  a.click();
  URL.revokeObjectURL(url);
}

Update File

const formData = new FormData();
formData.append('name', 'Updated Name');
formData.append('avatar', newAvatarFile);

const response = await fetch(
  'https://your-instance.com/api/records/v1/profiles/abc123',
  {
    method: 'PATCH',
    headers: {
      'Authorization': `Bearer ${authToken}`
    },
    body: formData
  }
);

React File Upload Component

import { useState } from 'react';

interface UploadResult {
  ids: string[];
}

function FileUpload({ apiName, authToken }: { apiName: string; authToken: string }) {
  const [file, setFile] = useState<File | null>(null);
  const [uploading, setUploading] = useState(false);
  const [error, setError] = useState<string | null>(null);

  const handleUpload = async (e: React.FormEvent) => {
    e.preventDefault();
    if (!file) return;

    setUploading(true);
    setError(null);

    try {
      const formData = new FormData();
      formData.append('title', file.name);
      formData.append('file', file);

      const response = await fetch(
        `https://your-instance.com/api/records/v1/${apiName}`,
        {
          method: 'POST',
          headers: {
            'Authorization': `Bearer ${authToken}`
          },
          body: formData
        }
      );

      if (!response.ok) {
        throw new Error(`Upload failed: ${response.statusText}`);
      }

      const result: UploadResult = await response.json();
      console.log('Upload successful:', result.ids[0]);
      
      setFile(null);
    } catch (err) {
      setError(err instanceof Error ? err.message : 'Upload failed');
    } finally {
      setUploading(false);
    }
  };

  return (
    <form onSubmit={handleUpload}>
      <input
        type="file"
        onChange={(e) => setFile(e.target.files?.[0] || null)}
        disabled={uploading}
      />
      <button type="submit" disabled={!file || uploading}>
        {uploading ? 'Uploading...' : 'Upload'}
      </button>
      {error && <div style={{ color: 'red' }}>{error}</div>}
    </form>
  );
}

Image Optimization

For image files, consider implementing client-side compression:
async function compressImage(file, maxWidth = 1200, quality = 0.8) {
  return new Promise((resolve) => {
    const reader = new FileReader();
    reader.onload = (e) => {
      const img = new Image();
      img.onload = () => {
        const canvas = document.createElement('canvas');
        let { width, height } = img;
        
        if (width > maxWidth) {
          height = (height * maxWidth) / width;
          width = maxWidth;
        }
        
        canvas.width = width;
        canvas.height = height;
        
        const ctx = canvas.getContext('2d');
        ctx.drawImage(img, 0, 0, width, height);
        
        canvas.toBlob((blob) => resolve(blob), 'image/jpeg', quality);
      };
      img.src = e.target.result;
    };
    reader.readAsDataURL(file);
  });
}

// Usage
const originalFile = fileInput.files[0];
const compressedBlob = await compressImage(originalFile);
const compressedFile = new File([compressedBlob], originalFile.name, {
  type: 'image/jpeg'
});

formData.append('avatar', compressedFile);

Direct File URLs

File download URLs are deterministic based on the record ID and column name:
/api/records/v1/{api_name}/{record_id}/file/{column_name}
/api/records/v1/{api_name}/{record_id}/files/{column_name}/{file_name}
You can construct URLs client-side without additional API calls:
function getAvatarUrl(recordId: string, authToken: string): string {
  return `https://your-instance.com/api/records/v1/profiles/${recordId}/file/avatar?token=${authToken}`;
}

// Use in img tag
<img src={getAvatarUrl(userId, authToken)} alt="Avatar" />
Never expose auth tokens in public URLs. Use cookie-based auth for public file access or implement signed URLs.

File Metadata API

File metadata is stored directly in the record. Fetch it via the standard read endpoint:
curl -X GET https://your-instance.com/api/records/v1/documents/abc123 \
  -H "Authorization: Bearer YOUR_AUTH_TOKEN"
{
  "id": "abc123",
  "title": "My Document",
  "file": {
    "name": "document.pdf",
    "size": 1024000,
    "content_type": "application/pdf",
    "objectstore_id": "storage-id"
  }
}

Best Practices

  1. Validate files client-side before upload to improve UX
  2. Compress images before upload to reduce bandwidth and storage
  3. Use appropriate content types for accurate file handling
  4. Implement progress indicators for large file uploads
  5. Handle upload failures gracefully with retry logic
  6. Clean up blob URLs after use to prevent memory leaks
  7. Set reasonable file size limits based on your use case
  8. Use signed URLs for public file access instead of embedding tokens
  9. Consider CDN integration for frequently accessed files
  10. Monitor storage usage and implement cleanup policies

Troubleshooting

Upload Fails with 413 Payload Too Large

Increase the server’s max upload size:
[server]
max_upload_size = "50MB"

Files Not Deleted After Record Deletion

  1. Check _file_deletions table for pending deletions
  2. Verify object storage credentials
  3. Review server logs for deletion errors
  4. Files are retried up to 10 times before being abandoned

Cannot Download File (403 Forbidden)

  1. Verify user has read access to the record
  2. Check row-level access rules
  3. Confirm file column is not excluded from API
  4. Ensure auth token is valid and not expired

File Upload Succeeds but Record Creation Fails

Files are automatically cleaned up when the transaction fails. This is expected behavior to prevent orphaned files.

Error Responses

400
Bad Request
Invalid file format, missing required fields, or constraint violation
403
Forbidden
Access denied by ACL or row-level access rule
404
Not Found
Record, column, or file not found
405
Method Not Allowed
API doesn’t support file operations or wrong HTTP method
413
Payload Too Large
File exceeds maximum upload size
500
Internal Server Error
Storage backend error or unexpected server failure

Build docs developers (and LLMs) love