> ## Documentation Index
> Fetch the complete documentation index at: https://mintlify.com/trailbaseio/trailbase/llms.txt
> Use this file to discover all available pages before exploring further.

# File Uploads

> Handling file uploads and object storage in TrailBase

TrailBase provides built-in file upload handling with automatic storage, JSON schema validation, and secure access controls.

## Overview

File upload features:

* **JSON Schema validation** - Enforce file types and constraints
* **Object storage** - Local filesystem or S3-compatible storage
* **Automatic cleanup** - Orphaned files are deleted
* **Content type detection** - MIME type inference
* **Record API integration** - Upload files as part of record creation/updates
* **Access control** - Files inherit record permissions

## Basic File Upload

### 1. Define Schema

Add a file upload column to your table:

```sql migrations/main/U1234567890__add_avatar_to_profiles.sql theme={null}
ALTER TABLE profiles ADD COLUMN avatar TEXT 
  CHECK(jsonschema('std.FileUpload', avatar, 'image/png, image/jpeg, image/webp'));
```

<Info>
  **File Upload Schema:**

  * `std.FileUpload` - Single file
  * `std.FileUploads` - Multiple files (array)
  * Second parameter: column name
  * Third parameter (optional): allowed MIME types
</Info>

### 2. Upload File from Client

<Tabs>
  <Tab title="TypeScript">
    ```typescript theme={null}
    import { initClient } from "trailbase";

    const client = initClient("http://localhost:4000");
    const profilesApi = client.records("profiles");

    // Get file from input
    const fileInput = document.querySelector<HTMLInputElement>('#avatar')!;
    const file = fileInput.files?.[0];

    if (file) {
      // Create profile with avatar
      await profilesApi.create({
        username: "alice",
        avatar: file,
      });
      
      console.log("Profile created with avatar!");
    }
    ```
  </Tab>

  <Tab title="React">
    ```tsx theme={null}
    import { useState } from "react";
    import { initClient } from "trailbase";

    const client = initClient("http://localhost:4000");

    function ProfileUpload() {
      const [file, setFile] = useState<File | null>(null);
      const [username, setUsername] = useState("");

      async function handleSubmit(e: React.FormEvent) {
        e.preventDefault();
        
        if (!file) return;

        const profilesApi = client.records("profiles");
        await profilesApi.create({
          username,
          avatar: file,
        });
        
        alert("Profile created!");
      }

      return (
        <form onSubmit={handleSubmit}>
          <input
            type="text"
            value={username}
            onChange={(e) => setUsername(e.target.value)}
            placeholder="Username"
          />
          
          <input
            type="file"
            accept="image/png,image/jpeg,image/webp"
            onChange={(e) => setFile(e.target.files?.[0] ?? null)}
          />
          
          <button type="submit">Upload</button>
        </form>
      );
    }
    ```
  </Tab>

  <Tab title="Dart/Flutter">
    ```dart theme={null}
    import 'package:trailbase/trailbase.dart';
    import 'package:image_picker/image_picker.dart';

    final client = Client("http://localhost:4000");

    Future<void> uploadAvatar() async {
      // Pick image
      final picker = ImagePicker();
      final image = await picker.pickImage(source: ImageSource.gallery);
      
      if (image != null) {
        final bytes = await image.readAsBytes();
        
        // Create profile with avatar
        await client.records("profiles").create({
          "username": "alice",
          "avatar": MultipartFile.fromBytes(
            bytes,
            filename: image.name,
          ),
        });
        
        print("Profile created with avatar!");
      }
    }
    ```
  </Tab>

  <Tab title="cURL">
    ```bash theme={null}
    # Upload file with multipart/form-data
    curl -X POST http://localhost:4000/api/records/profiles \
      -F 'username=alice' \
      -F 'avatar=@/path/to/avatar.png'
    ```
  </Tab>
</Tabs>

### 3. Access Uploaded Files

Files are automatically stored and accessible via the Record API:

```typescript theme={null}
import { initClient } from "trailbase";

const client = initClient("http://localhost:4000");
const profilesApi = client.records("profiles");

// Read profile with avatar
const profile = await profilesApi.read(1);

if (profile.avatar) {
  console.log("Avatar:", profile.avatar);
  // {
  //   id: "01234567-89ab-cdef-0123-456789abcdef",
  //   filename: "avatar_abc123.png",
  //   original_filename: "avatar.png",
  //   content_type: "image/png",
  //   mime_type: "image/png"
  // }
  
  // Construct URL to download file
  const fileUrl = `/api/records/profiles/${profile.id}/files/avatar/${profile.avatar.filename}`;
  console.log("File URL:", fileUrl);
}
```

## File Upload Schema

### Single File Upload

```sql theme={null}
CREATE TABLE posts (
  id    INTEGER PRIMARY KEY,
  title TEXT NOT NULL,
  -- Single image with type constraints
  image TEXT CHECK(jsonschema('std.FileUpload', image, 'image/png, image/jpeg'))
) STRICT;
```

### Multiple File Uploads

```sql theme={null}
CREATE TABLE posts (
  id      INTEGER PRIMARY KEY,
  title   TEXT NOT NULL,
  -- Multiple images
  gallery TEXT CHECK(jsonschema('std.FileUploads', gallery, 'image/png, image/jpeg'))
) STRICT;
```

Upload multiple files:

```typescript theme={null}
const postsApi = client.records("posts");

const files = Array.from(fileInput.files ?? []);

await postsApi.create({
  title: "My Post",
  gallery: files, // Array of File objects
});
```

### Mixed Content

Combine different file types:

```sql theme={null}
CREATE TABLE documents (
  id          INTEGER PRIMARY KEY,
  title       TEXT NOT NULL,
  -- PDF document
  pdf         TEXT CHECK(jsonschema('std.FileUpload', pdf, 'application/pdf')),
  -- Supporting images
  attachments TEXT CHECK(jsonschema('std.FileUploads', attachments))
) STRICT;
```

## File Metadata

The `FileUpload` type contains:

```typescript theme={null}
interface FileUpload {
  // Unique file identifier (UUID)
  id: string;
  
  // Unique filename for storage
  filename: string;
  
  // Original filename from upload
  original_filename?: string | null;
  
  // User-provided content type
  content_type?: string | null;
  
  // Inferred MIME type (more reliable)
  mime_type?: string | null;
}
```

<Note>
  **Always use `mime_type` for validation**, not `content_type`. The `mime_type` is inferred by TrailBase and cannot be spoofed by clients.
</Note>

## Accessing Files

### Download File

Files are served via the Record API:

```
/api/records/{table}/{record_id}/files/{column}/{filename}
```

Example:

```typescript theme={null}
const profile = await profilesApi.read(1);

if (profile.avatar) {
  const url = `/api/records/profiles/${profile.id}/files/avatar/${profile.avatar.filename}`;
  
  // Display image
  document.querySelector('img')!.src = url;
}
```

### Direct URL Construction

For convenience, construct URLs directly:

```typescript theme={null}
function getFileUrl(
  table: string,
  recordId: number | string,
  column: string,
  filename: string
): string {
  return `${baseUrl}/api/records/${table}/${recordId}/files/${column}/${filename}`;
}

// Usage
const avatarUrl = getFileUrl("profiles", 1, "avatar", profile.avatar.filename);
```

## Updating Files

### Replace File

```typescript theme={null}
const profilesApi = client.records("profiles");

// Upload new avatar (old one is automatically deleted)
await profilesApi.update(1, {
  avatar: newFile,
});
```

### Remove File

```typescript theme={null}
// Set column to null to delete file
await profilesApi.update(1, {
  avatar: null,
});
```

<Info>
  TrailBase automatically deletes orphaned files. When you replace or remove a file, the old file is queued for deletion.
</Info>

## File Storage

### Local Filesystem

By default, files are stored in `traildepot/uploads/`:

```
traildepot/
└── uploads/
    ├── 01/
    │   ├── 23/
    │   │   └── 01234567-89ab-cdef-0123-456789abcdef
    │   └── 45/
    │       └── 01234567-89ab-cdef-0123-456789abcdef
    └── 67/
        └── 89/
            └── 01234567-89ab-cdef-0123-456789abcdef
```

Files are organized by UUID prefix for efficient storage.

### S3-Compatible Storage

Configure S3 or compatible storage (MinIO, DigitalOcean Spaces, etc.):

```bash theme={null}
# Set environment variables
export OBJECT_STORE_TYPE=s3
export OBJECT_STORE_BUCKET=my-bucket
export OBJECT_STORE_REGION=us-east-1
export OBJECT_STORE_ACCESS_KEY_ID=your-access-key
export OBJECT_STORE_SECRET_ACCESS_KEY=your-secret-key

# Optional: Custom S3 endpoint
export OBJECT_STORE_ENDPOINT=https://s3.example.com

trail run
```

<Tip>
  For production deployments, use S3 or compatible storage for better scalability and CDN integration.
</Tip>

## Access Control

File access inherits record permissions:

```protobuf traildepot/config.textproto theme={null}
record_apis: [
  {
    name: "profiles"
    table_name: "profiles"
    acl_world: [READ]
    acl_authenticated: [CREATE, UPDATE]
    # Users can only update their own profile
    update_access_rule: "_ROW_.user = _USER_.id"
  }
]
```

With this configuration:

* Anyone can view profile avatars (world-readable)
* Only the profile owner can upload/change their avatar

### Private Files

```protobuf traildepot/config.textproto theme={null}
record_apis: [
  {
    name: "documents"
    table_name: "documents"
    acl_authenticated: [CREATE, READ, UPDATE, DELETE]
    # Users can only access their own documents
    read_access_rule: "_ROW_.user = _USER_.id"
  }
]
```

Files are only accessible to the document owner.

## Validation

### File Type Constraints

Restrict file types using MIME type patterns:

```sql theme={null}
-- Images only
image TEXT CHECK(jsonschema('std.FileUpload', image, 'image/*'))

-- Specific image formats
image TEXT CHECK(jsonschema('std.FileUpload', image, 'image/png, image/jpeg, image/webp'))

-- Documents
pdf TEXT CHECK(jsonschema('std.FileUpload', pdf, 'application/pdf'))

-- Videos
video TEXT CHECK(jsonschema('std.FileUpload', video, 'video/mp4, video/webm'))

-- Any file type
file TEXT CHECK(jsonschema('std.FileUpload', file))
```

### Client-Side Validation

```typescript theme={null}
function validateFile(file: File): boolean {
  // Check file size (5MB max)
  if (file.size > 5 * 1024 * 1024) {
    alert("File too large (max 5MB)");
    return false;
  }
  
  // Check file type
  const allowedTypes = ["image/png", "image/jpeg", "image/webp"];
  if (!allowedTypes.includes(file.type)) {
    alert("Invalid file type");
    return false;
  }
  
  return true;
}

// Usage
const file = fileInput.files?.[0];
if (file && validateFile(file)) {
  await profilesApi.create({
    username: "alice",
    avatar: file,
  });
}
```

<Warning>
  **Always validate on the server.** Client-side validation can be bypassed. TrailBase validates MIME types based on file content, not the provided `Content-Type` header.
</Warning>

## Real-World Example: Blog with Images

From the [Blog Example](https://github.com/trailbaseio/trailbase/tree/main/examples/blog):

```sql migrations/main/U1725019362__create_articles.sql theme={null}
CREATE TABLE articles (
    id           BLOB PRIMARY KEY NOT NULL CHECK(is_uuid_v7(id)) DEFAULT (uuid_v7()),
    author       BLOB NOT NULL REFERENCES _user(id) ON DELETE CASCADE,

    title        TEXT NOT NULL,
    intro        TEXT NOT NULL,
    tag          TEXT NOT NULL,
    body         TEXT NOT NULL,

    -- Article featured image
    image        TEXT CHECK(jsonschema('std.FileUpload', image, 'image/png, image/jpeg')),

    created      INTEGER DEFAULT (UNIXEPOCH()) NOT NULL
) STRICT;
```

Create article with image:

```typescript theme={null}
import { initClient } from "trailbase";

const client = initClient("http://localhost:4000");
const articlesApi = client.records("articles");

const fileInput = document.querySelector<HTMLInputElement>('#image')!;
const imageFile = fileInput.files?.[0];

await articlesApi.create({
  title: "My First Article",
  intro: "This is the introduction",
  tag: "technology",
  body: "Full article content...",
  image: imageFile,
});
```

Display article with image:

```tsx theme={null}
import { useEffect, useState } from "react";
import { initClient } from "trailbase";

const client = initClient("http://localhost:4000");

type Article = {
  id: string;
  title: string;
  intro: string;
  body: string;
  image?: {
    id: string;
    filename: string;
    mime_type: string;
  };
};

function Article({ id }: { id: string }) {
  const [article, setArticle] = useState<Article | null>(null);

  useEffect(() => {
    async function load() {
      const api = client.records<Article>("articles");
      const data = await api.read(id);
      setArticle(data);
    }
    load();
  }, [id]);

  if (!article) return <div>Loading...</div>;

  const imageUrl = article.image
    ? `/api/records/articles/${article.id}/files/image/${article.image.filename}`
    : null;

  return (
    <article>
      <h1>{article.title}</h1>
      {imageUrl && (
        <img
          src={imageUrl}
          alt={article.title}
          style={{ maxWidth: "100%" }}
        />
      )}
      <p>{article.intro}</p>
      <div dangerouslySetInnerHTML={{ __html: article.body }} />
    </article>
  );
}
```

## Image Optimization

### Client-Side Resizing

Resize images before upload to reduce bandwidth:

```typescript theme={null}
async function resizeImage(
  file: File,
  maxWidth: number,
  maxHeight: number
): Promise<Blob> {
  return new Promise((resolve) => {
    const img = new Image();
    img.onload = () => {
      const canvas = document.createElement("canvas");
      
      // Calculate dimensions
      let width = img.width;
      let height = img.height;
      
      if (width > maxWidth) {
        height = (height * maxWidth) / width;
        width = maxWidth;
      }
      
      if (height > maxHeight) {
        width = (width * maxHeight) / height;
        height = maxHeight;
      }
      
      canvas.width = width;
      canvas.height = height;
      
      const ctx = canvas.getContext("2d")!;
      ctx.drawImage(img, 0, 0, width, height);
      
      canvas.toBlob((blob) => {
        resolve(blob!);
      }, file.type);
    };
    
    img.src = URL.createObjectURL(file);
  });
}

// Usage
const file = fileInput.files?.[0];
if (file) {
  const resized = await resizeImage(file, 1200, 1200);
  
  await articlesApi.create({
    title: "Article",
    image: new File([resized], file.name, { type: file.type }),
  });
}
```

### WASM Image Processing

Process images server-side using WASM:

```typescript theme={null}
import { defineConfig } from "trailbase-wasm";
import { HttpHandler } from "trailbase-wasm/http";
import { query } from "trailbase-wasm/db";

export default defineConfig({
  httpHandlers: [
    HttpHandler.post("/api/images/thumbnail/:id", async (req) => {
      const id = req.getPathParam("id");
      
      // Get image from database
      const rows = await query(
        "SELECT image FROM articles WHERE id = ?",
        [id]
      );
      
      if (!rows.length) {
        return HttpResponse.json(
          { error: "Not found" },
          { status: 404 }
        );
      }
      
      // In production: resize image using WASM image library
      // For now, return original
      const image = rows[0][0];
      return HttpResponse.json({ thumbnail: image });
    }),
  ],
});
```

## Cleanup

### Automatic Cleanup

TrailBase automatically deletes orphaned files:

1. When a record is deleted, files are queued for deletion
2. When a file column is updated, the old file is queued
3. A background job processes the deletion queue

### Manual Cleanup

Query pending deletions:

```sql theme={null}
SELECT * FROM _file_deletions;
```

Force cleanup via SQL:

```sql theme={null}
-- Delete all pending files
DELETE FROM _file_deletions;
```

<Warning>
  Manual deletion bypasses TrailBase's cleanup mechanism. Only use for debugging.
</Warning>

## Best Practices

<Steps>
  <Step title="Validate File Types">
    Always specify allowed MIME types in your schema:

    ```sql theme={null}
    -- Good: Specific types
    avatar TEXT CHECK(jsonschema('std.FileUpload', avatar, 'image/png, image/jpeg'))

    -- Bad: Any type
    avatar TEXT CHECK(jsonschema('std.FileUpload', avatar))
    ```
  </Step>

  <Step title="Limit File Sizes">
    Implement client-side size limits:

    ```typescript theme={null}
    const MAX_SIZE = 5 * 1024 * 1024; // 5MB

    if (file.size > MAX_SIZE) {
      alert("File too large");
      return;
    }
    ```
  </Step>

  <Step title="Use Descriptive Column Names">
    Name columns based on their purpose:

    ```sql theme={null}
    -- Good
    profile_picture TEXT CHECK(...)
    resume_pdf TEXT CHECK(...)

    -- Bad
    file1 TEXT CHECK(...)
    file2 TEXT CHECK(...)
    ```
  </Step>

  <Step title="Optimize Images">
    Resize large images before upload:

    * Reduce dimensions to reasonable sizes (e.g., 1200x1200)
    * Compress quality for web delivery
    * Consider WebP format for better compression
  </Step>

  <Step title="Use CDN for Production">
    Serve files through a CDN for better performance:

    * Configure S3 with CloudFront
    * Use custom domain for files
    * Enable caching headers
  </Step>
</Steps>

## Troubleshooting

### File Upload Fails

**Symptom:** Upload returns 400 error

**Solutions:**

1. Check MIME type constraints in schema
2. Verify file is not corrupted
3. Check request `Content-Type` is `multipart/form-data`
4. Ensure file size is within limits

### File Not Found

**Symptom:** File URL returns 404

**Solutions:**

1. Verify record exists and contains file metadata
2. Check file column is not NULL
3. Verify `filename` matches exactly
4. Check access permissions

### Files Not Deleted

**Symptom:** Old files remain after update/delete

**Solutions:**

1. Check `_file_deletions` table for pending deletions
2. Wait for background cleanup job (runs periodically)
3. Verify object storage credentials are correct

## Next Steps

<CardGroup cols={2}>
  <Card title="First App" icon="rocket" href="/guides/first-app">
    Build an app with file uploads
  </Card>

  <Card title="Database Setup" icon="database" href="/guides/database-setup">
    Learn about file upload schemas
  </Card>

  <Card title="Authentication" icon="lock" href="/guides/authentication">
    Secure file uploads
  </Card>

  <Card title="WASM Runtime" icon="bolt" href="/guides/wasm-runtime">
    Process files with WebAssembly
  </Card>
</CardGroup>

## Examples

* [Blog Example](https://github.com/trailbaseio/trailbase/tree/main/examples/blog) - Articles with featured images
* [User Avatars](https://github.com/trailbaseio/trailbase/tree/main/examples/blog) - Profile avatars with authentication
