> ## Documentation Index
> Fetch the complete documentation index at: https://docs.encoreos.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Platform Realtime Layer - Migration Guide

> The @/platform/realtime module provides three shared hooks that replace all direct supabase.channel() usage:

**Version:** 1.0.0
**Last Updated:** 2026-02-09
**Spec:** `specs/pf/specs/PF-66-platform-realtime-layer.md`
**Plan:** `specs/pf/plans/PF-66-platform-realtime-layer-PLAN.md`

> **Purpose:** Step-by-step guide for migrating existing ad-hoc Supabase Realtime implementations to the shared `@/platform/realtime` module. Each section shows the exact before/after code for one implementation, the verification steps, and the expected outcome.

***

## Table of Contents

* [Overview](#overview)
* [Migration Principles](#migration-principles)
* [Migration 1: Notification Hooks (PF)](#migration-1-notification-hooks-pf)
* [Migration 2: Dashboard Widget Hook (PF)](#migration-2-dashboard-widget-hook-pf)
* [Migration 3: PendingActionsWidget Inline Subscription (PF)](#migration-3-pendingactionswidget-inline-subscription-pf)
* [Migration 4: Workflow Execution Hooks (FW)](#migration-4-workflow-execution-hooks-fw)
* [Migration 5: SMS Messages Hook (CE)](#migration-5-sms-messages-hook-ce)
* [Migration 6: Financial Close Broadcast (FA)](#migration-6-financial-close-broadcast-fa)
* [Adding New Real-Time Features](#adding-new-real-time-features)
* [Deprecation Timeline](#deprecation-timeline)
* [Troubleshooting](#troubleshooting)

***

## Overview

The `@/platform/realtime` module provides three shared hooks that replace all direct `supabase.channel()` usage:

| Hook                      | Replaces                                              | Use Case                                |
| ------------------------- | ----------------------------------------------------- | --------------------------------------- |
| `useRealtimeSubscription` | `supabase.channel().on('postgres_changes', ...)`      | Subscribe to table INSERT/UPDATE/DELETE |
| `useRealtimeBroadcast`    | `supabase.channel().send({ type: 'broadcast', ... })` | Fire-and-forget ephemeral messages      |
| `useRealtimePresence`     | `supabase.channel().track(...)`                       | Track who's viewing a resource          |

**Import:**

```typescript theme={null}
import {
  useRealtimeSubscription,
  useRealtimeBroadcast,
  useRealtimePresence,
  RealtimeConnectionBadge,
  PresenceAvatars,
} from '@/platform/realtime';
```

***

## Migration Principles

1. **Internal refactors only** -- External hook APIs (return types, option types) MUST NOT change
2. **Zero regressions** -- Run existing tests before and after each migration
3. **One hook at a time** -- Migrate and verify each hook separately, commit after each
4. **Core ownership** -- PF team migrates PF hooks; FW/CE teams migrate their own hooks
5. **Organization filter** -- Always include `organization_id` in the subscription filter (defense-in-depth)

***

## Migration 1: Notification Hooks (PF)

**Owner:** PF team
**Files:** `src/platform/notifications/useNotifications.ts`
**Impact:** Reduces 3 channels to 1 for `pf_notifications`

### 1a: `useNotifications` (list query + subscription)

**BEFORE:**

```typescript theme={null}
// src/platform/notifications/useNotifications.ts (lines 25-62)
export const useNotifications = (limit = 20) => {
  const query = useQuery({ /* ... existing query ... */ });

  // OLD: Inline subscription
  useEffect(() => {
    let channel: ReturnType<typeof supabase.channel> | null = null;

    const setupSubscription = async () => {
      const { data: { session } } = await supabase.auth.getSession();
      if (!session?.user) return;

      channel = supabase
        .channel('notifications-changes')
        .on('postgres_changes', {
          event: '*',
          schema: 'public',
          table: 'pf_notifications',
        }, () => {
          query.refetch();
        })
        .subscribe((status) => {
          if (status === 'CHANNEL_ERROR' || status === 'TIMED_OUT') {
            // Silently handle
          }
        });
    };
    setupSubscription();
    return () => { if (channel) supabase.removeChannel(channel); };
  }, [query]);

  return query;
};
```

**AFTER:**

```typescript theme={null}
// src/platform/notifications/useNotifications.ts
import { useRealtimeSubscription } from '@/platform/realtime';

export const useNotifications = (limit = 20) => {
  const query = useQuery({ /* ... existing query, unchanged ... */ });

  // NEW: Shared subscription (ChannelManager handles lifecycle)
  useRealtimeSubscription({
    table: 'pf_notifications',
    event: '*',
    filter: organizationId ? { organization_id: organizationId } : undefined,
    enabled: !!organizationId,
    onEvent: () => query.refetch(),
  });

  return query;
};
```

### 1b: `useUnreadCount` (count query + subscription)

**BEFORE:** Same inline `useEffect` pattern with a separate channel `'notifications-unread-changes'`.

**AFTER:**

```typescript theme={null}
export const useUnreadCount = () => {
  const query = useQuery({ /* ... existing query, unchanged ... */ });

  // NEW: Shares the SAME channel as useNotifications via ChannelManager multiplexing
  useRealtimeSubscription({
    table: 'pf_notifications',
    event: '*',
    onEvent: () => query.refetch(),
  });

  return query;
};
```

> **Key insight:** Both hooks subscribe to the same table (`pf_notifications`) with no filter. The `ChannelManager` automatically multiplexes them onto a single Supabase channel.

### 1c: `useNotificationToasts` (INSERT-only toast)

**BEFORE:**

```typescript theme={null}
// src/platform/notifications/useNotificationToasts.tsx (lines 17-86)
export function useNotificationToasts() {
  const { markAsRead } = useNotificationMutation();

  useEffect(() => {
    const setupSubscription = async () => {
      const { data: { session } } = await supabase.auth.getSession();
      if (!session?.user) return null;

      const channel = supabase
        .channel('notification-toasts')
        .on('postgres_changes', {
          event: 'INSERT',
          schema: 'public',
          table: 'pf_notifications',
          filter: 'channel=eq.in_app',
        }, (payload) => {
          const notification = payload.new as Notification;
          if (notification.channel === 'in_app') {
            toast({ title: notification.title, description: notification.body, /* ... */ });
          }
        })
        .subscribe();
      return channel;
    };
    // ... setup and cleanup
  }, [markAsRead]);
}
```

**AFTER:**

```typescript theme={null}
import { useRealtimeSubscription } from '@/platform/realtime';

export function useNotificationToasts() {
  const { markAsRead } = useNotificationMutation();

  useRealtimeSubscription({
    table: 'pf_notifications',
    event: 'INSERT',
    filter: { channel: 'in_app' },
    onInsert: (notification) => {
      if (notification.channel === 'in_app') {
        const toastProps: any = {
          title: notification.title,
          description: notification.body,
          duration: 5000,
        };
        if (notification.action_url) {
          toastProps.action = (
            <ToastAction altText="View notification" onClick={() => {
              window.location.href = notification.action_url!;
              markAsRead.mutate(notification.id);
            }}>
              View
            </ToastAction>
          );
        }
        toast(toastProps);
      }
    },
  });
}
```

### Verification

```bash theme={null}
npm run typecheck    # 0 errors
npm run build        # succeeds
npm run test:unit -- tests/unit/platform  # existing tests pass
```

**Expected outcome:** 3 Supabase channels reduced to 1 (or 2 if the `channel=eq.in_app` filter creates a distinct channel name).

***

## Migration 2: Dashboard Widget Hook (PF)

**Owner:** PF team
**Files:** `src/platform/dashboard/hooks/useRealtimeWidget.ts`

### BEFORE:

```typescript theme={null}
// src/platform/dashboard/hooks/useRealtimeWidget.ts (lines 27-121)
export const useRealtimeWidget = (
  widgetId: string,
  table: string,
  onUpdate: () => void,
  options: UseRealtimeWidgetOptions = {},
): RealtimeWidgetState => {
  const { filter, enabled = true } = options;
  const [state, setState] = useState<RealtimeWidgetState>({ /* ... */ });
  const channelRef = useRef<RealtimeChannel | null>(null);

  useEffect(() => {
    if (!enabled) { /* cleanup */ return; }
    const channel = supabase
      .channel(`widget-${widgetId}-${table}`)
      .on('postgres_changes',
        filter ? { event: '*', schema: 'public', table, filter } : { event: '*', schema: 'public', table },
        () => handleUpdate()
      )
      .subscribe((status) => { /* handle status */ });
    channelRef.current = channel;
    return () => { supabase.removeChannel(channel); };
  }, [widgetId, table, filter, enabled, handleUpdate]);

  return state;
};
```

### AFTER:

```typescript theme={null}
import { useRealtimeSubscription } from '@/platform/realtime';

export const useRealtimeWidget = (
  widgetId: string,
  table: string,
  onUpdate: () => void,
  options: UseRealtimeWidgetOptions = {},
): RealtimeWidgetState => {
  const { filter, enabled = true } = options;
  const onUpdateRef = useRef(onUpdate);
  useEffect(() => { onUpdateRef.current = onUpdate; }, [onUpdate]);

  // Parse "key=eq.value" filter string to record format
  const filterRecord = useMemo(() => {
    if (!filter) return undefined;
    const [key, value] = filter.split('=eq.');
    return key && value ? { [key]: value } : undefined;
  }, [filter]);

  const { isConnected, lastUpdated, error } = useRealtimeSubscription({
    table,
    event: '*',
    filter: filterRecord,
    enabled,
    onEvent: () => onUpdateRef.current(),
  });

  return { isConnected, lastUpdated, error };
};
```

**External API is identical** -- `BedCensusWidget`, `EmployeeCountWidget`, and all other consumers work unchanged.

### Verification

```bash theme={null}
npm run typecheck
npm run build
```

***

## Migration 3: PendingActionsWidget Inline Subscription (PF)

**Owner:** PF team
**Files:** `src/platform/dashboard/widgets/PendingActionsWidget.tsx`

### BEFORE:

```typescript theme={null}
// Lines 43-66: inline useEffect subscription
useEffect(() => {
  if (!userId) return;
  const channel = supabase
    .channel('pending-actions-realtime')
    .on('postgres_changes', {
      event: '*', schema: 'public', table: 'pf_notifications',
      filter: `user_id=eq.${userId}`,
    }, () => { refetch(); })
    .subscribe();
  return () => { supabase.removeChannel(channel); };
}, [userId, refetch]);
```

### AFTER:

```typescript theme={null}
import { useRealtimeWidget } from '../hooks/useRealtimeWidget';

// Replace the inline useEffect with the shared hook:
const realtimeState = useRealtimeWidget(
  'pending-actions',
  'pf_notifications',
  () => refetch(),
  { filter: userId ? `user_id=eq.${userId}` : undefined, enabled: !!userId }
);
```

Delete the entire `useEffect` block (lines 43-66).

### Verification

```bash theme={null}
npm run typecheck
npm run build
```

***

## Migration 4: Workflow Execution Hooks (FW)

**Owner:** FW team (or PF with migration guide)
**Files:** `src/cores/fw/hooks/useRealtimeExecutions.ts`

### 4a: `useRealtimeExecutions` (list with optimistic merge)

**BEFORE:** \~90 lines of inline channel setup with INSERT/UPDATE/DELETE switch statement.

**AFTER:**

```typescript theme={null}
import { useRealtimeSubscription } from '@/platform/realtime';

export const useRealtimeExecutions = (options: UseRealtimeExecutionsOptions = {}): UseRealtimeExecutionsReturn => {
  const { organizationId, statusFilter, workflowId, enabled = true } = options;
  const [executions, setExecutions] = useState<WorkflowExecution[]>([]);
  const [error, setError] = useState<Error | null>(null);

  // Initial fetch (unchanged)
  const fetchExecutions = useCallback(async () => { /* ... same as before ... */ }, [organizationId, statusFilter, workflowId]);

  useEffect(() => { if (enabled && organizationId) fetchExecutions(); }, [enabled, organizationId, fetchExecutions]);

  // NEW: Shared subscription with merge callbacks
  const { isConnected } = useRealtimeSubscription({
    table: 'fw_workflow_executions',
    filter: organizationId ? { organization_id: organizationId } : undefined,
    enabled: enabled && !!organizationId,
    onInsert: (newExecution) => {
      // Same filter + add logic as before
      if (statusFilter?.length && !statusFilter.includes(newExecution.status)) return;
      if (workflowId && newExecution.rule_id !== workflowId) return;
      setExecutions(prev => [newExecution, ...prev].slice(0, 100));
    },
    onUpdate: (updatedExecution) => {
      const shouldBeIncluded =
        (!statusFilter?.length || statusFilter.includes(updatedExecution.status)) &&
        (!workflowId || updatedExecution.rule_id === workflowId);
      if (!shouldBeIncluded) {
        setExecutions(prev => prev.filter(e => e.id !== updatedExecution.id));
        return;
      }
      setExecutions(prev => {
        const exists = prev.some(e => e.id === updatedExecution.id);
        return exists
          ? prev.map(e => e.id === updatedExecution.id ? updatedExecution : e)
          : [updatedExecution, ...prev].slice(0, 100);
      });
    },
    onDelete: (old) => {
      setExecutions(prev => prev.filter(e => e.id !== old.id));
    },
  });

  return { executions, isConnected, error, refetch: fetchExecutions };
};
```

### 4b: `useRealtimeExecution` (single record)

**AFTER:**

```typescript theme={null}
export const useRealtimeExecution = (executionId?: string, enabled = true) => {
  const [execution, setExecution] = useState<WorkflowExecution | null>(null);
  const [error, setError] = useState<Error | null>(null);

  // Initial fetch (unchanged)
  useEffect(() => { /* same fetch logic */ }, [enabled, executionId]);

  const { isConnected } = useRealtimeSubscription({
    table: 'fw_workflow_executions',
    event: 'UPDATE',
    filter: executionId ? { id: executionId } : undefined,
    enabled: enabled && !!executionId,
    onUpdate: (payload) => setExecution(payload),
  });

  return { execution, isConnected, error };
};
```

### Verification

```bash theme={null}
npm run typecheck
npm run test:integration -- tests/integration/fw-realtime-dashboard.test.ts
```

***

## Migration 5: SMS Messages Hook (CE)

**Owner:** CE team (or PF with migration guide)
**Files:** `src/cores/ce/hooks/useRealtimeSmsMessages.ts`

### BEFORE: \~100 lines of inline channel setup with local state merge and `matchesFilters` logic.

### AFTER:

```typescript theme={null}
import { useRealtimeSubscription } from '@/platform/realtime';

export function useRealtimeSmsMessages(options: UseRealtimeSmsMessagesOptions = {}): UseRealtimeSmsMessagesReturn {
  const { currentOrganization } = useOrganization();
  const organizationId = currentOrganization?.id;
  const { contactId, partnerId, leadId, phoneNumber, enabled = true } = options;

  const [messages, setMessages] = useState<SmsMessage[]>([]);
  const [error, setError] = useState<Error | null>(null);

  // matchesFilters (unchanged)
  const matchesFilters = useCallback((message: SmsMessage): boolean => {
    /* ... same logic ... */
  }, [contactId, partnerId, leadId, phoneNumber]);

  // Initial fetch (unchanged)
  const fetchMessages = useCallback(async () => { /* ... same ... */ }, [organizationId, contactId, partnerId, leadId, phoneNumber]);

  useEffect(() => { if (enabled && organizationId) fetchMessages(); }, [enabled, organizationId, fetchMessages]);

  // NEW: Shared subscription
  const { isConnected } = useRealtimeSubscription({
    table: 'ce_sms_messages',
    filter: organizationId ? { organization_id: organizationId } : undefined,
    enabled: enabled && !!organizationId,
    onInsert: (newMessage) => {
      if (!matchesFilters(newMessage) || newMessage.deleted_at) return;
      setMessages(prev => {
        if (prev.some(m => m.id === newMessage.id)) return prev;
        return [...prev, newMessage].slice(-100);
      });
    },
    onUpdate: (updatedMessage) => {
      if (updatedMessage.deleted_at) {
        setMessages(prev => prev.filter(m => m.id !== updatedMessage.id));
        return;
      }
      if (!matchesFilters(updatedMessage)) {
        setMessages(prev => prev.filter(m => m.id !== updatedMessage.id));
        return;
      }
      setMessages(prev => {
        const exists = prev.some(m => m.id === updatedMessage.id);
        return exists
          ? prev.map(m => m.id === updatedMessage.id ? updatedMessage : m)
          : [...prev, updatedMessage].slice(-100);
      });
    },
    onDelete: (old) => {
      setMessages(prev => prev.filter(m => m.id !== old.id));
    },
  });

  return { messages, isConnected, error };
}
```

### Verification

```bash theme={null}
npm run typecheck
npm run build
```

***

## Migration 6: Financial Close Broadcast (FA)

**Owner:** PF team (or FA team)
**Files:** `src/cores/fa/wizards/financial-close-setup/FinancialCloseSetupWizardPage.tsx`

### BEFORE:

```typescript theme={null}
async function publishCloseEvent(eventType: string, payload: { /* ... */ }) {
  try {
    const channel = supabase.channel('fa_events');
    await channel.send({
      type: 'broadcast',
      event: eventType,
      payload: { event_type: eventType, ...payload },
    });
  } catch { /* ... */ }
}
```

### AFTER:

```typescript theme={null}
import { useRealtimeBroadcast } from '@/platform/realtime';
import { buildChannelName } from '@/platform/realtime';

// Inside the component:
const { send } = useRealtimeBroadcast({
  channel: buildChannelName('broadcast', 'fa_events', organizationId),
});

// Replace publishCloseEvent calls with:
await send({
  event_type: eventType,
  organization_id: payload.organizationId,
  user_id: payload.userId,
  close_period_id: payload.closePeriodId,
  tasks_count: payload.tasksCount,
  assignments_count: payload.assignmentsCount,
});
```

### Verification

```bash theme={null}
npm run typecheck
npm run build
```

***

## Adding New Real-Time Features

After migration, adding real-time to any page takes 3-5 lines:

### Pattern A: Query Invalidation (most common)

```typescript theme={null}
import { useRealtimeSubscription, RealtimeConnectionBadge } from '@/platform/realtime';

function MyPage() {
  const queryClient = useQueryClient();
  const { currentOrganization } = useOrganization();

  const organizationId = currentOrganization?.id;

  const realtimeState = useRealtimeSubscription({
    table: 'my_table',
    filter: organizationId ? { organization_id: organizationId } : undefined,
    enabled: !!organizationId,
    onEvent: () => queryClient.invalidateQueries({ queryKey: ['my-query'] }),
  });

  return (
    <PageHeader action={<RealtimeConnectionBadge {...realtimeState} compact />}>
      {/* ... page content ... */}
    </PageHeader>
  );
}
```

### Pattern B: Optimistic Local Merge (chat / messaging)

```typescript theme={null}
const [items, setItems] = useState<Item[]>([]);

useRealtimeSubscription({
  table: 'my_table',
  filter: { organization_id: orgId },
  onInsert: (record) => setItems(prev => [...prev, record]),
  onUpdate: (record) => setItems(prev => prev.map(i => i.id === record.id ? record : i)),
  onDelete: (old) => setItems(prev => prev.filter(i => i.id !== old.id)),
});
```

### Pattern C: Ephemeral Broadcast (typing indicators, activity pulses)

```typescript theme={null}
import { useRealtimeBroadcast } from '@/platform/realtime';

const { send, lastMessage } = useRealtimeBroadcast({
  channel: buildChannelName('broadcast', 'typing', entityId),
  onMessage: (payload) => { /* handle incoming broadcast */ },
});

// Send event:
await send({ type: 'typing', userId: user.id });
```

### Pattern D: Presence (who's viewing this record)

```typescript theme={null}
import { useRealtimePresence, PresenceAvatars } from '@/platform/realtime';

const { users } = useRealtimePresence({
  channel: buildChannelName('presence', 'hr_employees', employeeId),
  userState: { name: user.name, avatar_url: user.avatar_url },
});

return <PresenceAvatars users={users} maxVisible={3} />;
```

***

## Deprecation Timeline

| Phase                     | When                  | What Happens                                                                                            |
| ------------------------- | --------------------- | ------------------------------------------------------------------------------------------------------- |
| **Phase 2** (migration)   | Weeks 1-2             | Old hooks refactored internally; external APIs unchanged; both old and new code exist                   |
| **Phase 3** (deprecation) | Week 3                | `@deprecated` JSDoc added to old hooks; ESLint warns on `supabase.channel()`; migration guide published |
| **Phase 4** (removal)     | After 1 release cycle | Deprecated files deleted; ESLint escalated to error; all imports point to `@/platform/realtime`         |

### What Gets Deprecated

| File                                                                             | Status After Phase 3                                  | Status After Phase 4                       |
| -------------------------------------------------------------------------------- | ----------------------------------------------------- | ------------------------------------------ |
| `src/platform/dashboard/hooks/useRealtimeWidget.ts`                              | `@deprecated` (wraps new hook internally)             | Thin wrapper kept OR deleted               |
| `src/platform/dashboard/components/RealtimeConnectionStatus.tsx`                 | `@deprecated` (re-exports `RealtimeConnectionBadge`)  | Deleted; import from `@/platform/realtime` |
| `src/platform/dashboard/hooks/useRealtimeWidget.ts` `useRealtimeAggregateStatus` | `@deprecated` (re-exports from `@/platform/realtime`) | Deleted                                    |
| Direct `supabase.channel()` usage in notification hooks                          | Already removed in Phase 2                            | N/A                                        |
| Direct `supabase.channel()` usage in `PendingActionsWidget`                      | Already removed in Phase 2                            | N/A                                        |

### What Stays

| Item                                                   | Reason                                                                |
| ------------------------------------------------------ | --------------------------------------------------------------------- |
| `useRealtimeWidget` export from `@/platform/dashboard` | Convenience wrapper for widget pattern; may be kept as thin re-export |
| `@/platform/realtime` module                           | New canonical location for all realtime functionality                 |

***

## Troubleshooting

### "Channel limit exceeded" warning

The `RealtimeProvider` logs a warning when channel count exceeds `maxChannels` (default: 20). This is advisory, not a hard block.

**Fix:** Check for components that mount/unmount rapidly, causing orphaned subscriptions. Verify `useRealtimeSubscription` cleanup runs on unmount.

### Notification toasts stopped appearing

**Check:** Verify the `filter: { channel: 'in_app' }` is correctly passed. The shared hook may create a different channel name than the old inline code if the filter format changed.

### "Not connected" badge when data is actually fresh

**Check:** The `isConnected` state may take a moment to update after subscription. Verify the `RealtimeProvider` is mounted in the component tree above the component using the hook.

### Multiple channels for the same table

**Check:** Verify channel names match. Two subscriptions to the same table but with different filters will create separate channels (by design). Use `useConnectionStatus()` to inspect total channel count.

***

## References

* **Spec:** `specs/pf/specs/PF-66-platform-realtime-layer.md`
* **Plan:** `specs/pf/plans/PF-66-platform-realtime-layer-PLAN.md`
* **Tasks:** `specs/pf/tasks/PF-66-platform-realtime-layer-TASKS.md`
* **Strategy:** [docs/architecture/REAL\_TIME\_ARCHITECTURE.md](/architecture/REAL_TIME_ARCHITECTURE)
* **Supabase Realtime Docs:** [https://supabase.com/docs/guides/realtime](https://supabase.com/docs/guides/realtime)

***

## Pattern 2: Broadcast (Fire-and-Forget Events)

### Before

```typescript theme={null}
const channel = supabase.channel('my_events');
await new Promise((resolve) => {
  channel.subscribe((status) => {
    if (status === 'SUBSCRIBED') resolve(undefined);
  });
});
await channel.send({
  type: 'broadcast',
  event: 'my_event',
  payload: { key: 'value' },
});
supabase.removeChannel(channel);
```

### After

```typescript theme={null}
import { useRealtimeBroadcast } from '@/platform/realtime';

const { send, isConnected } = useRealtimeBroadcast({
  channel: 'my_events',
  event: 'my_event',
  onMessage: (payload) => {
    // handle incoming broadcast
  },
});

// Send a broadcast
send({ key: 'value' });
```

***

## Pattern 3: Query Invalidation on Change

### Before

```typescript theme={null}
const queryClient = useQueryClient();

useEffect(() => {
  const channel = supabase
    .channel('notifications-changes')
    .on('postgres_changes', { event: '*', schema: 'public', table: 'pf_notifications' }, () => {
      queryClient.invalidateQueries({ queryKey: ['notifications'] });
    })
    .subscribe();
  return () => supabase.removeChannel(channel);
}, [queryClient]);
```

### After

```typescript theme={null}
const query = useQuery({ queryKey: ['notifications'], queryFn: ... });

useRealtimeSubscription({
  table: 'pf_notifications',
  event: '*',
  onEvent: () => query.refetch(),
});
```

***

## Migration Checklist

* [ ] Replace `supabase.channel()` with appropriate hook (`useRealtimeSubscription`, `useRealtimeBroadcast`, or `useRealtimePresence`)
* [ ] Remove `useEffect` cleanup (`supabase.removeChannel()`) — hooks handle this
* [ ] Remove `import { supabase }` if no longer needed
* [ ] Remove `useQueryClient` if only used for invalidation (use `query.refetch()` instead)
* [ ] Verify `autoInjectOrgId` behavior (default `true` — set `false` for user-scoped subscriptions)
* [ ] Add `RealtimeConnectionBadge` to page headers where appropriate
* [ ] Run `npm run lint` to verify no `supabase.channel()` violations

***

## Troubleshooting

### "Channel limit exceeded" errors

**Symptom:** Console warning about exceeding max channels.

**Diagnose:** Check `useConnectionStatus().activeChannels` — if it's near or above `realtime_max_channels` (default 20), you have too many concurrent subscriptions.

**Fix:**

* Increase `realtime_max_channels` in `pf_module_settings` (max 100)
* Review components for redundant subscriptions (e.g., multiple hooks on the same table)
* Ensure hooks are cleaned up on unmount (they should be if using `useRealtimeSubscription`)

### Connection debugging with `useConnectionStatus`

```typescript theme={null}
const health = useConnectionStatus();
// health.activeChannels — number of live channels
// health.totalChannels — total managed channels
// health.hasErrors — true if any channel errored
```

Use `<RealtimeConnectionBadge isConnected={health.activeChannels > 0} />` to visually monitor.

### Missing `RealtimeProvider`

**Symptom:** Hooks silently fail or `channelManager` is `undefined`.

**Fix:** Ensure `<RealtimeProvider>` is mounted **inside** `<OrganizationProvider>` in `App.tsx`. The provider needs organization context for tenant-scoped settings.

### Fallback when realtime is unavailable

If the WebSocket connection drops, hooks stop receiving events. Add a polling fallback:

```typescript theme={null}
const query = useQuery({
  queryKey: ['my-data'],
  queryFn: fetchData,
  refetchInterval: isConnected ? false : 30000, // Poll every 30s when disconnected
});
```

### Migration error guidance

| Old Pattern                                           | New Hook                                                  |
| ----------------------------------------------------- | --------------------------------------------------------- |
| `supabase.channel().on('postgres_changes', ...)`      | `useRealtimeSubscription`                                 |
| `supabase.channel().send({ type: 'broadcast', ... })` | `useRealtimeBroadcast` (or `sendBroadcast` for non-React) |
| `channel.track()` / `channel.on('presence', ...)`     | `useRealtimePresence`                                     |

See `src/platform/realtime/README.md` for complete API docs.

***

## API Reference

See `src/platform/realtime/README.md` for complete API documentation.
