AgentSkillsCN

supabase-patterns

Expo 应用中的 Supabase 后端模式:身份验证、行级安全、迁移、边缘函数、实时订阅以及类型生成。适用于设置 Supabase、编写迁移脚本、配置 RLS 策略、创建边缘函数,或进行身份验证调试时使用。

SKILL.md
--- frontmatter
name: supabase-patterns
description: >
  Supabase backend patterns for Expo apps: authentication, Row Level Security, migrations,
  edge functions, real-time subscriptions, and type generation. Use when setting up Supabase,
  writing migrations, configuring RLS policies, creating edge functions, or debugging auth.

Supabase Patterns — Backend Conventions

Standard patterns for Supabase backends in Expo/iOS apps.

Client Setup

typescript
// src/lib/supabase.ts
import 'react-native-url-polyfill/auto';
import { createClient } from '@supabase/supabase-js';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { Database } from '@/types/database';

const supabaseUrl = process.env.EXPO_PUBLIC_SUPABASE_URL!;
const supabaseAnonKey = process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY!;

export const supabase = createClient<Database>(supabaseUrl, supabaseAnonKey, {
  auth: {
    storage: AsyncStorage,
    autoRefreshToken: true,
    persistSession: true,
    detectSessionInUrl: false, // Important for React Native
  },
});

Critical: Always use Database generic for type safety. Never use untyped client.

Type Generation

bash
# Generate types from remote database
npx supabase gen types typescript --project-id YOUR_PROJECT_ID > src/types/database.ts

# Generate from local database
npx supabase gen types typescript --local > src/types/database.ts

Type Helper Pattern

typescript
// src/types/database.ts (append after generation)
export type Tables<T extends keyof Database['public']['Tables']> =
  Database['public']['Tables'][T]['Row'];
export type Insertable<T extends keyof Database['public']['Tables']> =
  Database['public']['Tables'][T]['Insert'];
export type Updatable<T extends keyof Database['public']['Tables']> =
  Database['public']['Tables'][T]['Update'];

// Usage: Tables<'profiles'>, Insertable<'todos'>, etc.

Re-generate types after every migration. This is non-negotiable.

Authentication Patterns

Auth State Management

typescript
// src/hooks/useAuth.ts
import { useEffect } from 'react';
import { supabase } from '@/lib/supabase';
import { useAuthStore } from '@/stores/useAuthStore';

export function useAuth() {
  const { session, setSession, isLoading, setIsLoading } = useAuthStore();

  useEffect(() => {
    // Get initial session
    supabase.auth.getSession().then(({ data: { session } }) => {
      setSession(session);
      setIsLoading(false);
    });

    // Listen for auth changes
    const { data: { subscription } } = supabase.auth.onAuthStateChange(
      (_event, session) => {
        setSession(session);
      }
    );

    return () => subscription.unsubscribe();
  }, []);

  return { session, isLoading, user: session?.user ?? null };
}

Auth Methods

typescript
// src/services/api/auth.ts
import { supabase } from '@/lib/supabase';

export async function signInWithEmail(email: string, password: string) {
  const { data, error } = await supabase.auth.signInWithPassword({
    email,
    password,
  });
  if (error) throw error;
  return data;
}

export async function signUpWithEmail(email: string, password: string) {
  const { data, error } = await supabase.auth.signUp({
    email,
    password,
  });
  if (error) throw error;
  return data;
}

export async function signInWithApple() {
  // Apple Sign-In for iOS
  const { data, error } = await supabase.auth.signInWithIdToken({
    provider: 'apple',
    token: 'id_token_from_apple',
  });
  if (error) throw error;
  return data;
}

export async function signOut() {
  const { error } = await supabase.auth.signOut();
  if (error) throw error;
}

Auth Rules

  1. Always handle both signUp and signIn errors distinctly
  2. Use signInWithIdToken for Apple Sign-In (required for iOS App Store)
  3. Set up email confirmation for production
  4. Store nothing auth-related in AsyncStorage manually — Supabase handles it
  5. Always check session before making authenticated requests

Migration Patterns

Migration File Naming

code
supabase/migrations/YYYYMMDDHHMMSS_descriptive_name.sql

Example: 20250214120000_create_profiles_table.sql

Standard Table Template

sql
-- supabase/migrations/20250214120000_create_profiles.sql

create table public.profiles (
  id uuid references auth.users(id) on delete cascade primary key,
  email text not null,
  full_name text,
  avatar_url text,
  created_at timestamptz default now() not null,
  updated_at timestamptz default now() not null
);

-- Enable RLS
alter table public.profiles enable row level security;

-- Create updated_at trigger
create or replace function public.handle_updated_at()
returns trigger as $$
begin
  new.updated_at = now();
  return new;
end;
$$ language plpgsql;

create trigger set_updated_at
  before update on public.profiles
  for each row execute function public.handle_updated_at();

-- Auto-create profile on signup
create or replace function public.handle_new_user()
returns trigger as $$
begin
  insert into public.profiles (id, email, full_name)
  values (
    new.id,
    new.email,
    coalesce(new.raw_user_meta_data->>'full_name', '')
  );
  return new;
end;
$$ language plpgsql security definer;

create trigger on_auth_user_created
  after insert on auth.users
  for each row execute function public.handle_new_user();

Migration Rules

  1. One concern per migration (don't mix table creation with seed data)
  2. Always enable RLS immediately after creating a table
  3. Always add created_at and updated_at to every table
  4. Use uuid for primary keys, reference auth.users(id) for user-owned data
  5. Use on delete cascade thoughtfully — understand what it destroys
  6. Test migrations locally before pushing: npx supabase db reset

Row Level Security (RLS)

Standard Policy Templates

sql
-- Users can read their own data
create policy "Users can read own data"
  on public.profiles for select
  using (auth.uid() = id);

-- Users can update their own data
create policy "Users can update own data"
  on public.profiles for update
  using (auth.uid() = id)
  with check (auth.uid() = id);

-- Users can insert their own data
create policy "Users can insert own data"
  on public.todos for insert
  with check (auth.uid() = user_id);

-- Users can delete their own data
create policy "Users can delete own data"
  on public.todos for delete
  using (auth.uid() = user_id);

-- Public read access (e.g., for a blog)
create policy "Public read access"
  on public.posts for select
  using (published = true);

RLS Rules

  1. Every table MUST have RLS enabled — no exceptions
  2. Default deny: if no policy matches, access is denied
  3. Always use auth.uid() — never trust client-supplied user IDs
  4. using = filter for SELECT/UPDATE/DELETE (which rows can be read)
  5. with check = validation for INSERT/UPDATE (which rows can be written)
  6. Test policies by switching between different user JWTs locally

Service Layer Pattern

typescript
// src/services/api/todos.ts
import { supabase } from '@/lib/supabase';
import { Tables, Insertable } from '@/types/database';

export async function getTodos(): Promise<Tables<'todos'>[]> {
  const { data, error } = await supabase
    .from('todos')
    .select('*')
    .order('created_at', { ascending: false });

  if (error) throw error;
  return data;
}

export async function createTodo(todo: Insertable<'todos'>): Promise<Tables<'todos'>> {
  const { data, error } = await supabase
    .from('todos')
    .insert(todo)
    .select()
    .single();

  if (error) throw error;
  return data;
}

export async function updateTodo(
  id: string,
  updates: Partial<Insertable<'todos'>>
): Promise<Tables<'todos'>> {
  const { data, error } = await supabase
    .from('todos')
    .update(updates)
    .eq('id', id)
    .select()
    .single();

  if (error) throw error;
  return data;
}

export async function deleteTodo(id: string): Promise<void> {
  const { error } = await supabase.from('todos').delete().eq('id', id);
  if (error) throw error;
}

Service Rules

  1. Every function returns typed data or throws
  2. Always .select() after .insert() / .update() to get the result
  3. Use .single() when expecting exactly one row
  4. Never expose raw Supabase client in components
  5. Group queries by table/feature in separate files

Real-time Subscriptions

typescript
// src/hooks/useRealtimeTodos.ts
import { useEffect } from 'react';
import { supabase } from '@/lib/supabase';

export function useRealtimeTodos(onUpdate: () => void) {
  useEffect(() => {
    const channel = supabase
      .channel('todos-changes')
      .on(
        'postgres_changes',
        { event: '*', schema: 'public', table: 'todos' },
        () => onUpdate()
      )
      .subscribe();

    return () => {
      supabase.removeChannel(channel);
    };
  }, [onUpdate]);
}

Edge Functions

typescript
// supabase/functions/process-image/index.ts
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts';
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2';

serve(async (req) => {
  try {
    // Create authenticated client from request
    const supabase = createClient(
      Deno.env.get('SUPABASE_URL')!,
      Deno.env.get('SUPABASE_ANON_KEY')!,
      {
        global: { headers: { Authorization: req.headers.get('Authorization')! } },
      }
    );

    const { data: { user } } = await supabase.auth.getUser();
    if (!user) return new Response('Unauthorized', { status: 401 });

    // Your logic here
    const body = await req.json();

    return new Response(JSON.stringify({ success: true }), {
      headers: { 'Content-Type': 'application/json' },
    });
  } catch (error) {
    return new Response(JSON.stringify({ error: error.message }), {
      status: 500,
      headers: { 'Content-Type': 'application/json' },
    });
  }
});

Storage Patterns

typescript
// Upload image
async function uploadAvatar(userId: string, file: ImagePickerAsset) {
  const fileExt = file.uri.split('.').pop();
  const filePath = `${userId}/avatar.${fileExt}`;

  const formData = new FormData();
  formData.append('file', {
    uri: file.uri,
    name: `avatar.${fileExt}`,
    type: `image/${fileExt}`,
  } as any);

  const { error } = await supabase.storage
    .from('avatars')
    .upload(filePath, formData, { upsert: true });

  if (error) throw error;

  const { data } = supabase.storage.from('avatars').getPublicUrl(filePath);
  return data.publicUrl;
}

Environment Variables

bash
# .env.local (never commit)
EXPO_PUBLIC_SUPABASE_URL=https://xxx.supabase.co
EXPO_PUBLIC_SUPABASE_ANON_KEY=eyJhb...

# Access in code
process.env.EXPO_PUBLIC_SUPABASE_URL

Rule: Only EXPO_PUBLIC_ prefixed vars are available in client code. Secrets go in Supabase Edge Function environment, not in the app.