managarten/memoro/apps/mobile/docs/SUPABASE_REALTIME_RLS_WORKAROUND.md
Till-JS e7f5f942f3 chore: initial commit - consolidate 4 projects into monorepo
Projects included:
- maerchenzauber (NestJS backend + Expo mobile + SvelteKit web + Astro landing)
- manacore (Expo mobile + SvelteKit web + Astro landing)
- manadeck (NestJS backend + Expo mobile + SvelteKit web)
- memoro (Expo mobile + SvelteKit web + Astro landing)

This commit preserves the current state before monorepo restructuring.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-22 23:38:24 +01:00

8.5 KiB

Supabase Realtime RLS Workaround: Broadcast Channels

Problem Statement

When using Supabase Edge Functions with service_role keys to update database records, the updates bypass Row Level Security (RLS) policies. However, Supabase Realtime still respects RLS policies even for service_role operations. This means:

  • Database updates work (service_role bypasses RLS)
  • Realtime subscriptions don't receive updates (Realtime respects RLS)

This is a known limitation documented in Supabase GitHub issue #226.

Symptoms

  • Edge functions successfully update records in the database
  • Client-side realtime subscriptions don't receive these updates
  • Updates only appear when manually refreshing data or switching views
  • Problem occurs specifically when edge functions use service_role for operations

Solution: Broadcast Channels

Use Supabase Broadcast Channels to manually notify clients when edge functions make updates. This creates a parallel communication channel that bypasses the RLS limitation.

Implementation Guide

1. Client-Side: Create a Broadcast Subscription Service

// memoRealtimeService.ts
class MemoRealtimeService {
  private supabaseClient: any = null;

  /**
   * Subscribe to a broadcast channel for receiving updates
   * This is useful for receiving updates from service_role operations that bypass RLS
   */
  subscribeToBroadcastChannel(
    channelName: string, 
    callback: (payload: any) => void
  ): () => void {
    if (!this.supabaseClient) {
      console.warn('No authenticated client available for broadcast subscription');
      return () => {};
    }

    console.log(`Subscribing to broadcast channel: ${channelName}`);
    
    const channel = this.supabaseClient.channel(channelName);
    
    channel
      .on('broadcast', { event: '*' }, (payload: any) => {
        console.log(`Broadcast received on ${channelName}:`, payload);
        callback(payload);
      })
      .subscribe((status: string) => {
        console.log(`Broadcast channel ${channelName} status:`, status);
      });

    // Return unsubscribe function
    return () => {
      console.log(`Unsubscribing from broadcast channel: ${channelName}`);
      channel.unsubscribe();
    };
  }

  /**
   * Get current data without subscription
   */
  async getCurrentMemoData(memoId: string): Promise<any | null> {
    if (!this.supabaseClient) {
      return null;
    }
    
    try {
      const { data: memo, error } = await this.supabaseClient
        .from('memos')
        .select('*')
        .eq('id', memoId)
        .single();
        
      if (error) {
        console.error('Error fetching memo data:', error);
        return null;
      }
      
      return memo;
    } catch (error) {
      console.error('Error in getCurrentMemoData:', error);
      return null;
    }
  }
}

2. Client-Side: Subscribe to Updates for Specific Records

// In your React component or similar
useEffect(() => {
  if (memoId) {
    // Subscribe to broadcast channel for this specific memo
    const broadcastUnsubscribe = memoRealtimeService.subscribeToBroadcastChannel(
      `memo-updates-${memoId}`,
      async (payload) => {
        console.log('📡 Broadcast update received:', payload);
        
        // Handle the nested payload structure from broadcast
        const broadcastData = payload.payload || payload;
        
        if (broadcastData.type === 'memo-updated' && broadcastData.memoId === memoId) {
          // Fetch fresh data when broadcast is received
          const freshData = await memoRealtimeService.getCurrentMemoData(memoId);
          if (freshData) {
            // Update your local state with fresh data
            updateLocalState(freshData);
          }
        }
      }
    );

    // Cleanup on unmount
    return () => {
      broadcastUnsubscribe();
    };
  }
}, [memoId]);

3. Edge Function: Send Broadcast After Updates

// In your Supabase Edge Function
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2';

const supabase = createClient(SUPABASE_URL, SERVICE_KEY);

// After updating the database record
const { error: updateError } = await supabase
  .from('memos')
  .update({
    title: newTitle,
    updated_at: new Date().toISOString()
  })
  .eq('id', memoId);

if (!updateError) {
  // Send broadcast update to notify clients
  try {
    const channel = supabase.channel(`memo-updates-${memoId}`);
    
    // Subscribe first to ensure the channel is ready
    channel.subscribe(async (status) => {
      if (status === 'SUBSCRIBED') {
        await channel.send({
          type: 'broadcast',
          event: 'memo-updated',
          payload: {
            type: 'memo-updated',
            memoId: memoId,
            changes: {
              title: newTitle,
              updated_at: new Date().toISOString()
            },
            source: 'your-edge-function-name'
          }
        });
        console.log(`Broadcast sent for memo ${memoId} update`);
        
        // Clean up the channel after sending
        supabase.removeChannel(channel);
      }
    });
  } catch (broadcastError) {
    console.warn('Failed to send broadcast update:', broadcastError);
    // Don't fail the function if broadcast fails
  }
}

Key Considerations

1. Channel Naming Convention

Use a consistent naming pattern for channels:

  • {resource}-updates-{resourceId} (e.g., memo-updates-123)
  • This allows targeted updates for specific records

2. Payload Structure

Supabase broadcasts wrap payloads in an extra level. The actual structure is:

{
  "event": "memo-updated",
  "type": "broadcast",
  "payload": {
    "type": "memo-updated",
    "memoId": "123",
    "changes": {...},
    "source": "edge-function-name"
  }
}

Always access the nested payload.payload in your client code.

3. Channel Cleanup

Important: Always clean up channels to prevent memory leaks:

  • Client-side: Return and call the unsubscribe function
  • Edge functions: Call supabase.removeChannel(channel) after sending

4. Error Handling

  • Broadcast failures should not fail your edge function
  • Wrap broadcast logic in try-catch blocks
  • Log failures for debugging but continue execution

5. Subscribe Before Sending

In edge functions, subscribe to the channel before sending to ensure it's ready:

channel.subscribe(async (status) => {
  if (status === 'SUBSCRIBED') {
    // Now safe to send
  }
});

Alternative Approaches

Some suggest switching to user context in edge functions, but this:

  • Requires passing user tokens to edge functions
  • Adds complexity and security concerns
  • May not work for all use cases

2. Separate Notification Table

Create a separate table without RLS for notifications:

  • More complex to maintain
  • Requires additional database operations
  • The broadcast approach is cleaner

Periodically fetch data from the client:

  • Inefficient and wastes resources
  • Poor user experience with delays
  • Should be avoided

Testing the Implementation

  1. Verify Broadcast Reception:

    // Add detailed logging
    console.log('📡 Broadcast update received:', JSON.stringify(payload, null, 2));
    
  2. Check Channel Status:

    .subscribe((status: string) => {
      console.log(`Channel status: ${status}`);
      // Should see: SUBSCRIBED
    });
    
  3. Monitor Edge Function Logs:

    • Verify "Broadcast sent" messages appear
    • Check for any error messages

Common Issues and Solutions

Issue: Updates Not Received

  • Check: Is the channel name consistent between sender and receiver?
  • Check: Are you accessing payload.payload for the nested structure?
  • Check: Is the channel subscription active before sending?

Issue: Memory Leaks

  • Solution: Always cleanup channels
  • Solution: Use unique channel names per resource
  • Solution: Implement proper unsubscribe logic

Issue: Delayed Updates

  • Solution: Ensure edge function subscribes before sending
  • Solution: Add small delay after subscription if needed
  • Solution: Check network latency

Example: Complete Implementation

See the Memoro app implementation:

  • Client: /app/(protected)/(tabs)/index.tsx
  • Service: /features/memos/services/memoRealtimeService.ts
  • Edge Functions: /supabase/functions/headline/index.ts

This pattern can be adapted for any Supabase project facing RLS limitations with service_role operations.