Github|...

File buckets

Upload, download and isolate files per user, declared in your schema like any other resource.

Experimental

File Buckets are experimental. The API may change in future releases. A bucket on the memory backend loses its files when SurrealDB restarts; use a persistent file: backend for anything you want to keep.

Buckets provide a type-safe file storage abstraction for uploading, downloading, and managing files in your Sp00ky application. They integrate directly with your SurrealDB schema for permission control and with the Sp00ky CLI for guided setup.

Defining a Bucket

Buckets are defined in SurrealQL using the DEFINE BUCKET statement. Create a .surql file in your src/buckets/ directory:

DEFINE BUCKET IF NOT EXISTS profile_pictures BACKEND "memory"
  PERMISSIONS WHERE
    $action NOT IN ['put']
    OR (
      string::ends_with(file::key($file), '.jpg')
      OR string::ends_with(file::key($file), '.jpeg')
      OR string::ends_with(file::key($file), '.png')
      OR string::ends_with(file::key($file), '.gif')
    )
  COMMENT 'sp00ky:maxSize=5242880';

Permission Variables

  • $action: the operation: 'put', 'get', 'head', 'delete', 'copy', 'rename', 'exists' or 'list'
  • file::key($file): the file path/key being acted on
  • $target: the destination key, on 'copy' and 'rename' only

This example allows unrestricted reads and deletes, and restricts uploads to the four image extensions. list is denied to record and guest sessions by the engine itself, whatever the clause says.

Size limits are a client-side rule

COMMENT 'sp00ky:maxSize=<bytes>' is read by spky into the bucket’s generated config, and useFileUpload rejects an oversized file before it leaves the browser.

Note

Do not express a size limit as a permission predicate. SurrealDB evaluates a bucket’s PERMISSIONS clause BEFORE the upload exists, so file::head($file).size there is the size of whatever is already stored at that key, not the size of what is being uploaded. A file::head($file).size <= N gate therefore lets every first upload through at any size, and denies legitimate overwrites of any file that is already over the limit. Older schemas carry that predicate because SurrealDB 3.0.x did not enforce per-file bucket actions at all; 3.1.x does.

Configuration

Reference your bucket files in sp00ky.yml:

# sp00ky.yml, `mode` is one of `singlenode`, `cluster`, or `surrealism`.
mode: cluster
buckets:
  - ./schema/src/buckets/profile.surql

After adding or modifying a bucket, regenerate your types by running spky so the bucket names and config are available in your generated schema.

Persistent storage

Set deployment.storage.sizeGB in sp00ky.yml and give the bucket a file: backend under /buckets:

buckets:
  - ./src/buckets/uploads.surql
deployment:
  storage:
    sizeGB: 20
DEFINE BUCKET IF NOT EXISTS uploads BACKEND "file:/buckets/uploads"
  PERMISSIONS WHERE $auth.id != NONE;

spky bucket backend <name> persistent rewrites an existing bucket’s backend for you, and spky warns when a bucket’s backend and the storage setting do not match.

  • Locally, spky dev mounts .sp00ky/bucket_data/ at /buckets.
  • On Sp00ky Cloud, the files live on a volume of their own of sizeGB: a hard cap that only affects this project, and that grows in place when you raise the number. It cannot shrink. Starter includes 5 GB and Pro 20 GB, and storage above that is billed per GB-month. See Deploying → Bucket Storage.
  • On a Pro project’s own VMs, the scheduler no longer mounts the volume, so the dashboard does not list storage subfolders.

R2 storage (any database)

A SurrealDB bucket keeps its files wherever its server can, and some servers can only keep them in memory: Surreal Cloud allows only the memory backend, so files there vanish when the database restarts while the rows pointing at them stay. Declare such a bucket BACKEND "r2" and the SSP keeps the files instead:

DEFINE BUCKET IF NOT EXISTS profile_pictures BACKEND "r2"
  PERMISSIONS WHERE $action NOT IN ['put'] OR $auth.id != NONE
  COMMENT 'sp00ky:maxSize=5242880';
  • On a Cloudflare project (pay as you go), spky deploy asks for an R2 bucket bound to the project’s SSP Worker, and bakes the Worker’s URL into the frontend build as VITE_SPKY_FILES_ENDPOINT.
  • Locally, spky dev keeps the files in the SSP’s data directory and sets VITE_SPKY_FILES_ENDPOINT to the local SSP.
  • The client reads and writes the files over HTTP at <endpoint>/bucket/<name>/<path> with the signed-in user’s token. Pass the endpoint in the client config; the bucket API (put, read, url, BucketImage, …) is unchanged.
export const dbConfig: SyncedDbConfig<typeof schema> = {
  schema,
  schemaSurql: SURQL_SCHEMA,
  // Where r2 bucket files are read and written: the SSP.
  files: { endpoint: import.meta.env.VITE_SPKY_FILES_ENDPOINT },
  database: { /* ... */ },
};

Permissions stay SurrealDB’s: the bucket’s PERMISSIONS clause becomes fn::_00_bucket::<name>($action, $file, $target), and the SSP calls it as the requesting user before every operation, so $auth, $action, $file and $target mean what they mean in the clause. Two differences from a SurrealDB bucket: the clause runs with the user’s table permissions (SurrealDB evaluates a bucket clause with them off), and signed-out requests are refused. Listing an r2 bucket is refused, as SurrealDB refuses it to record users.

spky bucket backend <name> r2 switches an existing bucket. The R2 backend is served by a Cloudflare project’s SSP; on the VM plans use a persistent file: backend.

CLI: Adding a Bucket

The fastest way to add a bucket is with the CLI:

spky bucket add

This walks you through an interactive setup with presets for common use cases:

PresetExtensionsMax SizePath Isolation
Avatarsjpg, jpeg, png, gif, webp2 MBYes
Imagesjpg, jpeg, png, gif, webp, svg10 MBNo
Documentspdf, doc, docx, txt, csv, xlsx25 MBNo
Videomp4, webm, mov, avi100 MBNo
Audiomp3, wav, ogg, flac, aac50 MBNo
CustomYou chooseYou chooseYou choose

The CLI generates the .surql file and updates your sp00ky.yml automatically.

Generated Schema

After running spky, your generated schema includes bucket metadata for type-safe access:

export const schema = {
  // ...tables, relationships, etc.
  buckets: [
    {
      name: 'profile_pictures' as const,
      maxSize: 5242880,
      allowedExtensions: ['jpg', 'jpeg', 'png', 'gif'] as const,
    },
  ],
} as const;

Bucket names are extracted as a union type, so db.bucket('...') only accepts valid bucket names from your schema.

Core API

Access a bucket from your Sp00kyClient or SyncedDb instance:

const bucket = db.bucket('profile_pictures');

// Upload a file
await bucket.put('avatars/user1.jpg', fileBytes);

// Download a file
const data = await bucket.get('avatars/user1.jpg');

// Check if a file exists
const exists = await bucket.exists('avatars/user1.jpg');

// Delete a file
await bucket.delete('avatars/user1.jpg');

// List files by prefix
const files = await bucket.list('avatars/');

// Get file metadata
const meta = await bucket.head('avatars/user1.jpg');

// Copy a file
await bucket.copy('avatars/user1.jpg', 'backups/user1.jpg');

// Rename a file
await bucket.rename('avatars/old.jpg', 'avatars/new.jpg');

SolidJS Hooks

useFileUpload

Upload files with built-in validation against your bucket configuration (max size, allowed extensions):

import { useFileUpload } from '@spooky-sync/client-solid';

function AvatarUpload() {
  const { upload, isUploading, error, clearError } = useFileUpload('profile_pictures');

  const handleFile = async (e: Event) => {
    const file = (e.target as HTMLInputElement).files?.[0];
    if (!file) return;

    await upload(\`avatars/\${userId}.jpg\`, file);
  };

  return (
    <div>
      <input type="file" accept="image/*" onChange={handleFile} />
      {isUploading() && <span>Uploading...</span>}
      {error() && <span>{error()!.message}</span>}
    </div>
  );
}

The hook validates file size and extension before attempting the upload, providing instant feedback.

API

const {
  isUploading: Accessor<boolean>,
  error: Accessor<Error | null>,
  clearError: () => void,
  upload: (path: string, file: File | Blob) => Promise<void>,
  download: (path: string) => Promise<string | null>,
  remove: (path: string) => Promise<void>,
  exists: (path: string) => Promise<boolean>,
} = useFileUpload(bucketName);

useDownloadFile

Reactively download and cache files as blob URLs:

import { useDownloadFile } from '@spooky-sync/client-solid';

function ProfilePicture(props: { path: string | null }) {
  const { url, isLoading, error } = useDownloadFile(
    'profile_pictures',
    () => props.path
  );

  return (
    <div>
      {isLoading() && <span>Loading...</span>}
      {url() && <img src={url()!} alt="Profile" />}
      {error() && <span>Failed to load image</span>}
    </div>
  );
}

Features:

  • Caching: Downloaded files are cached with reference counting. Disable with { cache: false }.
  • Deduplication: Concurrent downloads for the same path are deduplicated.
  • Auto-cleanup: Blob URLs are revoked when the component unmounts or the path changes.
  • Reactivity: Automatically re-fetches when the path accessor changes.

API

const {
  url: Accessor<string | null>,
  isLoading: Accessor<boolean>,
  error: Accessor<Error | null>,
  refetch: () => void,
} = useDownloadFile(bucketName, pathAccessor, options?);

// Options
interface UseDownloadFileOptions {
  cache?: boolean;  // default: true
}

Per-User Path Isolation

When enabled (via the pathPrefixAuth option in the CLI), file paths are automatically prefixed with the authenticated user’s ID. This ensures users can only access their own files.

// With path isolation enabled, uploading to "avatar.jpg"
// actually stores the file at "<user_id>/avatar.jpg"
await upload('avatar.jpg', file);
Note

Per-user path isolation is enforced at the database level. The path prefix is applied transparently. Your application code uses simple paths.

Local Caching

Bucket reads go through a durable client-side cache. Bytes land in OPFS under sp00ky-blobs/<bucketId>/<bucket>/<path>, so a file downloaded once survives a reload, paints instantly on the next visit, and is available offline. The cache is namespaced per signed-in user and is on by default wherever OPFS is writable; elsewhere it degrades to per-tab memory.

Nothing expires on a timer. A file is dropped only when the app invalidates its path (put() and delete() do this for you), when boot reconcile finds no file behind a manifest row, or when the cache exceeds its byte budget — in which case the least-recently-used unpinned entries that nothing is currently rendering go first.

const client = new Sp00kyClient({
// ...
blobCache: {
  enabled: true,          // default
  maxBytes: 256 * 1024 * 1024,  // default: min(512 MB, quota × 0.25)
  clearOnSignOut: false,  // default — cached files are per-user namespaced
},
});

const bucket = client.bucket('profile_pictures');
await bucket.prefetch(paths);   // warm for offline use
bucket.pin('logo.png');         // never evicted under pressure
await bucket.evict('old.png');  // drop locally, leave the remote file alone
Note

Live counters — cached bytes, budget, hits/misses, evictions — are in the DevTools Storage tab under “Bucket file cache”.

Current Limitations

  • Memory, disk or R2: a bucket is in memory (lost on restart), on the project’s persistent volume, or in R2 behind the SSP. S3 and other object stores are planned.
  • No streaming: Files are uploaded and downloaded as complete blobs. No chunked upload or progress tracking yet.
  • No transactions: Bucket operations do not participate in database transactions.