Skip to content

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 IDStatusUse caseRequires
supabaseProductionCloud deployments, multi-user, RLS-enforced mediaRunning Supabase instance
localDev / CLINo-cloud local development, CLI tooling, offline use~/.lenserfight/ directory
r2ProductionCloudflare 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:

bash
# Use local file system (no Supabase required)
DATA_SOURCE=file

# Use Supabase Storage (default)
DATA_SOURCE=supabase

At 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:

typescript
import { setDefaultStorageAdapter } from '@lenserfight/infra/storage'
setDefaultStorageAdapter('local')

StorageAdapterPort interface ​

All adapters implement this contract (libs/infra/storage/src/lib/storage.types.ts):

typescript
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'
MethodDescription
createSignedUploadUrlReturns a URL the client can PUT a file to directly, without server-side streaming
deleteObjectPermanently removes a file from storage
getPublicUrlReturns a stable public URL (only meaningful for public buckets)
getSignedDownloadUrlReturns a time-limited URL for secure file download
listObjectsLists objects under a prefix, up to limit results

Design principles (GRASP) ​

PrincipleApplied how
Pure FabricationStorageRegistry (storage.registry.ts) is a fabricated factory class — not a domain concept — that manages adapter lifecycle
Protected VariationsAll consumers depend on StorageAdapterPort, not on concrete adapters; swapping backends requires zero business-code changes
Low CouplingAdapter selection is externalised to an env var or a single setDefaultStorageAdapter() call
Information ExpertEach 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) ​

BucketPublicMax sizePurpose
lens-resourcesNo50 MBLens version file attachments
user-mediaNo20 MBUser-uploaded media
artifactsNo100 MBExecution output artifacts
public-assetsYes10 MBPublic 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}.json

config.json shape:

json
{
  "defaultAdapterId": "local",
  "authToken": "<stored-session-or-developer-token>",
  "supabaseAnonKey": "<optional-if-switching-to-supabase-later>",
  "cloudApiUrl": "https://api.lenserfight.com"
}

Current limitations ​

LimitationDetail
Browser local adapter is in-memoryLocalFileStorageAdapter 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 envSet 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 modeThe local adapter does not enforce RLS or access control. It is intended for single-developer local use only.
No multi-user in local modemedia.objects workspace scoping is not enforced when running without Supabase.