Skip to content

Data Access Strategy

This is the single most important rule in the codebase.

supabase.from() is banned in src/. It keeps table names and schema off the network wire. Every data path is one of exactly two shapes.

The decision rule

Pure SQL read + no secrets + no external HTTP → RPC. Everything else → Edge Function.

Reads → TanStack Query + RPC

  • useQuery calls a portable service that wraps supabase.rpc('get_…').
  • supabase.rpc() appears only in hooks/services, never in components/pages/JSX.
  • RPCs are SECURITY DEFINER + SET search_path = public + REVOKE ALL FROM PUBLIC / GRANT EXECUTE.
  • Standard hook shape: { data, isLoading, error, refetch }, per-feature queryKey, staleTime by data type.
  • Use row_to_json(table.*) in RPCs for forward-compat (don't project bare column lists that leak schema shape).

Writes / secrets / external HTTP / multi-step → Edge Function

  • useMutation calls services/{feature}Service.tssupabase.functions.invoke(EDGE_FN.X).
  • supabase.functions.invoke() appears only in services.
  • Invalidate queries on success so reads refetch.
  • EF names come from a per-feature EDGE_FN config mapnever inline strings.
ts
// constants/edgeFunctions.ts  (per feature)
export const EDGE_FN = {
  MANAGE_PROFILE: 'manage-profile',
  VALIDATE_INPUT: 'validate-user-input',
} as const

// services/profileService.ts
import { EDGE_FN } from '../constants/edgeFunctions'
const { data, error } = await supabase.functions.invoke(EDGE_FN.MANAGE_PROFILE, { body })

Defense in depth

RLS is not the primary write gate — it is defense-in-depth. The Edge Function is the primary write-enforcement layer. Both are always required: an EF validates + authorizes + mutates, and RLS still guards the table underneath.

[TRANSITIONAL] — the migration in progress

  • ~56 files still call from() directly. Being migrated feature-by-feature with parity verification. Do not add new from() calls.
  • Two caching layers coexist: TanStack Query and src/lib/cacheUtils.js. Converge on TanStack Query and retire cacheUtils.js.
  • A "never calls from()" compliance test is part of the target test model (see Testing Strategy).

Why this matters

BenefitHow the rule delivers it
SecurityTable names/schema never appear on the wire; RLS + EF enforce access twice.
PortabilityServices/hooks are DOM-free → the future Expo app reuses them unchanged.
PerformanceComposite RPCs avoid ≥3 parallel read RPCs saturating the pool on multi-section screens.
Refactor safetyrow_to_json projections are forward-compatible; callers don't break on column changes.