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.