Skip to content

Supabase

Status: Reference

Status: Active Last reviewed: 2026-06-27 Part of: External APIs index


Role

Primary backend for TwoMore. Provides:

  • PostgreSQL — all relational data (clubs, sessions, members, dues, matches)
  • Row-Level Security (RLS) — authorization enforced at the DB layer
  • Auth — JWT issuance; Kakao tokens are exchanged here for session JWTs
  • Storage — avatar images and any future file uploads
  • Freshness substrate — TanStack Query + focus refetch + push + focused polling; persistent Postgres-Changes channels are currently retired
  • Edge Functions — push dispatch, scenario seeding, weather, static maps, account export, PortOne verification/webhooks, and other trusted server work

Credentials

Env varValue shapeScope
EXPO_PUBLIC_SUPABASE_URLhttps://<project-ref>.supabase.coPublic — safe in bundle
EXPO_PUBLIC_SUPABASE_PUBLISHABLE_KEYsb_publishable_* or equivalent publishable keyPublic — preferred client key
EXPO_PUBLIC_SUPABASE_ANON_KEYJWT with role=anonPublic — RLS enforces access
TWOMORE_SECRET_KEYSupabase secret keyEdge Functions only — never ship in client

Security model: publishable/anon client keys are intentionally public. All row-level access control lives in PostgreSQL RLS policies, not in key secrecy. The service key must never appear in EXPO_PUBLIC_* or in the app bundle.

Where to find them

Supabase dashboard → Settings → API.


Setup (local dev)

bash
# Start local Supabase stack (Docker required)
npx supabase start

# Apply all migrations to local DB
npx supabase db reset

# Regenerate TypeScript types after schema changes
npx supabase gen types typescript --local \
  | npx prettier --config .prettierrc.json --parser typescript \
  > packages/app/src/adapters/supabase/generated.types.ts

Always regenerate types before writing a new adapter. Stale types are a silent source of bugs.


Free tier quotas

ResourceFree limitTwoMore usage pattern
Database size500 MB~1 KB/row → ~500k rows before upgrade
Storage1 GBAvatar images only in Phase 1
Realtime connectionsSee Supabase planCurrently zero persistent app sockets
Edge Function invocationsSee Supabase planActively used
Auth MAUs50,000Far exceeds Phase 1 target

Upgrade trigger: move to Pro ($25/month) when DB approaches 400 MB or MAUs approach 40k. Auth remains free up to 50k on Pro as well.


Hex-arch wiring

ports/repositories/*.repository.port.ts
  ↕  (implements)
adapters/supabase/*.supabase.ts         ← only layer that imports @supabase/supabase-js
  ↕  (uses)
adapters/supabase/generated.types.ts   ← never leaks past this boundary
adapters/supabase/mappers/*.mapper.ts  ← snake_case DB rows ↔ camelCase domain entities

Rules:

  • @supabase/supabase-js imports are forbidden outside packages/app/src/adapters/supabase/
  • generated.types.ts types must not be re-exported or used in ports/domain
  • Every adapter uses an explicit mapper; no camelcaseKeys() or generic transforms

Error handling

Supabase client methods return { data, error } — never throw.

typescript
// Adapter pattern
const { data, error } = await supabase.from('clubs').select('*').eq('id', id).single();
if (error) throw new RepositoryError(`Club not found: ${id}`, { cause: error });
return clubMapper.toDomain(data);

Map Postgres/PostgREST error codes to domain errors inside the adapter. The domain and UI never see raw Supabase errors.

CodeMeaningDomain action
23505Unique constraint violationThrow DuplicateError
23503Foreign key violationThrow ReferenceError
42501RLS permission deniedThrow PermissionError
PGRST116Row not found (.single())Return null or throw NotFoundError
PGRST301JWT expiredSurface to auth layer → re-login

RLS policy conventions

Each table has policies for: SELECT, INSERT, UPDATE, DELETE. Policy naming: {table}_{operation}_{who} — e.g. sessions_select_member.

Auth helper available in policies:

sql
auth.uid()          -- current user's UUID
auth.role()         -- 'anon' | 'authenticated'

Custom claims (club roles) are stored in club_members.role and joined in policies, not in JWT custom claims — this avoids token bloat and keeps roles in sync with DB state.


Freshness / realtime posture

Persistent Supabase Realtime WebSocket channels are currently retired. Live session surfaces use focus-gated polling through useLiveSessionData; DM uses native query refetchInterval; signals use push notification handlers plus cache-bust; ordinary surfaces use staleTime and mutation invalidation.

If a future realtime hook is reintroduced, it must live under packages/app/src/presentation/hooks/realtime/, use useFocusedEffect, and target specific cache keys. @twomore/no-realtime-subscribe-outside-realtime-hooks and @twomore/require-is-focused-in-realtime-hook are kept as regression guards.


Storage conventions

BucketContentAccess
avatarsProfile photosPublic read, authenticated write

File path format: avatars/{userId}/{timestamp}.{ext}

Never store full URLs in the DB — store only the relative path and reconstruct with getPublicUrl() in the storage adapter.


Edge Functions

Edge Functions live under supabase/functions/{name}/index.ts. Trusted functions use TWOMORE_SECRET_KEY (set via npx supabase secrets set) for service-authority work. Direct Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') reads are forbidden by @twomore/no-legacy-service-role-key-in-edge-functions.

Markdown remains the source of truth. Run yarn docs:check before handoff.