Storage Adapters
LenserFight decouples file and media storage from application logic through a StorageAdapterPort interface. You can swap backends — Supabase Storage, local file system, or Cloudflare R2 — without changing any business code.
Adapter overview
| Adapter ID | Status | Use case | Requires |
|---|---|---|---|
supabase | Production | Cloud deployments, multi-user, RLS-enforced media | Running Supabase instance |
local | Dev / CLI | No-cloud local development, CLI tooling, offline use | ~/.lenserfight/ directory |
r2 | Production | Cloudflare R2 object storage (S3-compatible) | R2 account, bucket, API keys; optional R2_PUBLIC_URL CDN |
Selecting an adapter
Set the DATA_SOURCE environment variable before starting the app or CLI:
# Use local file system (no Supabase required)
DATA_SOURCE=file
# Use Supabase Storage (default)
DATA_SOURCE=supabaseAt runtime, storage.registry.ts maps the env value to a concrete adapter:
DATA_SOURCE → storage.registry.ts → StorageAdapterPort implementation
"file" getStorageAdapter() LocalFileStorageAdapter
"supabase" setDefaultStorageAdapter SupabaseStorageAdapter
(custom) (register your own)You can also override programmatically:
import { setDefaultStorageAdapter } from '@lenserfight/infra/storage'
setDefaultStorageAdapter('local')StorageAdapterPort interface
All adapters implement this contract (libs/infra/storage/src/lib/storage.types.ts):
interface StorageAdapterPort {
createSignedUploadUrl(bucket: string, objectKey: string): Promise<{ signedUrl: string; token: string }>
deleteObject(bucket: string, objectKey: string): Promise<void>
getPublicUrl(bucket: string, objectKey: string): string
getSignedDownloadUrl(bucket: string, objectKey: string, expiresIn?: number): Promise<string>
listObjects(bucket: string, prefix: string, limit?: number): Promise<StorageListItem[]>
}
interface StorageListItem {
name: string
id: string | null
size: number // bytes
createdAt: string // ISO 8601
}
type StorageAdapterId = 'supabase' | 'local' | 'r2'| Method | Description |
|---|---|
createSignedUploadUrl | Returns a URL the client can PUT a file to directly, without server-side streaming |
deleteObject | Permanently removes a file from storage |
getPublicUrl | Returns a stable public URL (only meaningful for public buckets) |
getSignedDownloadUrl | Returns a time-limited URL for secure file download |
listObjects | Lists objects under a prefix, up to limit results |
Design principles (GRASP)
| Principle | Applied how |
|---|---|
| Pure Fabrication | StorageRegistry (storage.registry.ts) is a fabricated factory class — not a domain concept — that manages adapter lifecycle |
| Protected Variations | All consumers depend on StorageAdapterPort, not on concrete adapters; swapping backends requires zero business-code changes |
| Low Coupling | Adapter selection is externalised to an env var or a single setDefaultStorageAdapter() call |
| Information Expert | Each adapter encapsulates its own backend details — callers never know whether they're writing to Supabase, disk, or R2 |
Media lifecycle
All adapters integrate with the media database schema (or its local equivalent). The flow is the same regardless of adapter:
1. CREATE → INSERT into media.objects (lifecycle_state = 'pending')
2. SIGN → adapter.createSignedUploadUrl(bucket, objectKey)
3. UPLOAD → Browser/CLI PUTs file to the signed URL
4. FINALIZE → Call fn_media_finalize_upload RPC (sets lifecycle_state = 'active')
5. ATTACH → Call fn_media_bind_attachment RPC (links object to entity)Inline text shortcut: If contentText is set on a media.objects row, steps 2–4 are skipped — no file upload needed. Useful for small text artifacts.
Storage buckets (Supabase adapter)
| Bucket | Public | Max size | Purpose |
|---|---|---|---|
lens-resources | No | 50 MB | Lens version file attachments |
user-media | No | 20 MB | User-uploaded media |
artifacts | No | 100 MB | Execution output artifacts |
public-assets | Yes | 10 MB | Public thumbnails and previews |
~/.lenserfight/ directory layout (local adapter)
When DATA_SOURCE=file, the local adapter reads and writes from the user's home directory:
~/.lenserfight/
├── config.json # Auth tokens, default adapter id, global preferences
├── lenses/ # Lens metadata — one JSON file per lens
│ └── {id}.json
├── lensers/ # Lenser profile data
│ └── {handle}.json
├── media/ # Raw file bytes, organised by storage bucket
│ ├── {bucket}/
│ │ └── {objectKey} # file content bytes
│ └── objects.json # media.objects catalog (metadata index)
├── workflows/ # Workflow definitions
│ └── {id}.json
└── agents/ # Agent definitions
└── {id}.jsonconfig.json shape:
{
"defaultAdapterId": "local",
"authToken": "<stored-session-or-developer-token>",
"supabaseAnonKey": "<optional-if-switching-to-supabase-later>",
"cloudApiUrl": "https://api.lenserfight.com"
}Current limitations
| Limitation | Detail |
|---|---|
| Browser local adapter is in-memory | LocalFileStorageAdapter uses an in-memory Map in the browser — data is lost on page reload. File-system backing (~/.lenserfight/) applies to CLI use only. |
| R2 requires env | Set R2_ACCOUNT_ID, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, R2_BUCKET, and optionally R2_PUBLIC_URL for CDN URLs. Use setDefaultStorageAdapter('r2') or wire via deployment config. |
| No auth in local mode | The local adapter does not enforce RLS or access control. It is intended for single-developer local use only. |
| No multi-user in local mode | media.objects workspace scoping is not enforced when running without Supabase. |
Related
- Local File Storage Tutorial — step-by-step: start without Supabase
- Local Database Setup — Supabase local setup and the no-Supabase alternative
- Environment Variables —
DATA_SOURCEand adapter config - Database Schema: Media —
media.objectsandmedia.attachmentstable reference