Skip to main content
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:
migrations/main/U1234567890__add_avatar_to_profiles.sql
ALTER TABLE profiles ADD COLUMN avatar TEXT 
  CHECK(jsonschema('std.FileUpload', avatar, 'image/png, image/jpeg, image/webp'));
File Upload Schema:
  • std.FileUpload - Single file
  • std.FileUploads - Multiple files (array)
  • Second parameter: column name
  • Third parameter (optional): allowed MIME types

2. Upload File from Client

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!");
}

3. Access Uploaded Files

Files are automatically stored and accessible via the Record API:
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

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

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:
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:
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:
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;
}
Always use mime_type for validation, not content_type. The mime_type is inferred by TrailBase and cannot be spoofed by clients.

Accessing Files

Download File

Files are served via the Record API:
/api/records/{table}/{record_id}/files/{column}/{filename}
Example:
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:
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

const profilesApi = client.records("profiles");

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

Remove File

// Set column to null to delete file
await profilesApi.update(1, {
  avatar: null,
});
TrailBase automatically deletes orphaned files. When you replace or remove a file, the old file is queued for deletion.

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.):
# 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
For production deployments, use S3 or compatible storage for better scalability and CDN integration.

Access Control

File access inherits record permissions:
traildepot/config.textproto
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

traildepot/config.textproto
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:
-- 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

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,
  });
}
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.

Real-World Example: Blog with Images

From the Blog Example:
migrations/main/U1725019362__create_articles.sql
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:
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:
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:
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:
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:
SELECT * FROM _file_deletions;
Force cleanup via SQL:
-- Delete all pending files
DELETE FROM _file_deletions;
Manual deletion bypasses TrailBase’s cleanup mechanism. Only use for debugging.

Best Practices

1

Validate File Types

Always specify allowed MIME types in your schema:
-- Good: Specific types
avatar TEXT CHECK(jsonschema('std.FileUpload', avatar, 'image/png, image/jpeg'))

-- Bad: Any type
avatar TEXT CHECK(jsonschema('std.FileUpload', avatar))
2

Limit File Sizes

Implement client-side size limits:
const MAX_SIZE = 5 * 1024 * 1024; // 5MB

if (file.size > MAX_SIZE) {
  alert("File too large");
  return;
}
3

Use Descriptive Column Names

Name columns based on their purpose:
-- Good
profile_picture TEXT CHECK(...)
resume_pdf TEXT CHECK(...)

-- Bad
file1 TEXT CHECK(...)
file2 TEXT CHECK(...)
4

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
5

Use CDN for Production

Serve files through a CDN for better performance:
  • Configure S3 with CloudFront
  • Use custom domain for files
  • Enable caching headers

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

First App

Build an app with file uploads

Database Setup

Learn about file upload schemas

Authentication

Secure file uploads

WASM Runtime

Process files with WebAssembly

Examples

Build docs developers (and LLMs) love