Storage
The filesystem, S3, or a backend of your own.
A file is stored under its path, photos/2024/sunset.jpg, and a folder is a prefix. The fs backend keeps that layout under uploads.url, .uploads by default, resolved against your app's root. The UPLOADS_URL env var overrides url.
Storing files in S3
For S3 and S3-compatible services, install @ohnejs/s3.
pnpm add @ohnejs/s3import { defineConfig } from 'ohnejs';
export default defineConfig({
layers: ['ohnejs/base', 'ohnejs/uploads', '@ohnejs/s3'],
uploads: { storage: 's3', url: 's3://my-bucket/uploads?region=eu-central-1' },
});Set AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY. The package README covers bucket setup, private files, and services such as R2 and MinIO.
A backend of your own
Register a backend from a boot file and select it by name:
import { useStorages } from 'ohnejs/uploads';
import { createGCSStorage } from '../storage/gcs.ts';
useStorages().register('gcs', (url) => createGCSStorage(url));import { defineConfig } from 'ohnejs';
export default defineConfig({
layers: ['ohnejs/base', 'ohnejs/uploads'],
uploads: { storage: 'gcs', url: 'gs://my-bucket/uploads' },
});A backend implements StorageAdapter; its JSDoc names each method. The rules it cannot show:
moveanddeletetake a prefix as well as a file, so a folder is one call.- The layer journals each effect inside the row's transaction and replays it after a crash, so a missing path must be a no-op.
setPrivatelocks an object a backend serves itself, as a row turns private. Without it, a private object stays readable atpublicURL, and ohne warns at boot.- A backend that serves its files itself stores the
dispositionthatwritereceives, as@ohnejs/s3does. Without it, an uploaded HTML file runs as a page on that origin. listletsohne uploads prunefind stray files. Without it, the command refuses.partslets a file go up in resumable chunks. Without it, opening a session answers501, and the dashboard sends every file whole.
Stray files
A file can end up in storage with no row behind it, after a database restored from an older backup or a file copied in by hand. The ohne uploads prune command lists those files:
npx ohne uploads pruneOnce the list holds only files you can lose, --delete deletes them. A file that is still uploading is never among them. Nor is an unfinished resumable upload: once its session expires, the next boot or this command sweeps its chunks away.
An upload cut off by a crash, a SIGKILL, or a passed deadline leaves a partial file under .tmp/, and the first boot once a day has passed deletes it.
storage and url are each layer's own: a dependency cannot point your uploads at its storage.