Skip to main content

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.

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

The @/platform/realtime module provides three shared hooks that replace all direct supabase.channel() usage:
HookReplacesUse Case
useRealtimeSubscriptionsupabase.channel().on('postgres_changes', ...)Subscribe to table INSERT/UPDATE/DELETE
useRealtimeBroadcastsupabase.channel().send({ type: 'broadcast', ... })Fire-and-forget ephemeral messages
useRealtimePresencesupabase.channel().track(...)Track who’s viewing a resource
Import:
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:
// 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:
// 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:
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:
// 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:
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

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:

// 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:

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 identicalBedCensusWidget, EmployeeCountWidget, and all other consumers work unchanged.

Verification

npm run typecheck
npm run build

Migration 3: PendingActionsWidget Inline Subscription (PF)

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

BEFORE:

// 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:

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

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:
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:
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

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:

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

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:

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:

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

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)

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)

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)

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)

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

PhaseWhenWhat Happens
Phase 2 (migration)Weeks 1-2Old 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 cycleDeprecated files deleted; ESLint escalated to error; all imports point to @/platform/realtime

What Gets Deprecated

FileStatus After Phase 3Status 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 hooksAlready removed in Phase 2N/A
Direct supabase.channel() usage in PendingActionsWidgetAlready removed in Phase 2N/A

What Stays

ItemReason
useRealtimeWidget export from @/platform/dashboardConvenience wrapper for widget pattern; may be kept as thin re-export
@/platform/realtime moduleNew 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


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

Before

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

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

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

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

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:
const query = useQuery({
  queryKey: ['my-data'],
  queryFn: fetchData,
  refetchInterval: isConnected ? false : 30000, // Poll every 30s when disconnected
});

Migration error guidance

Old PatternNew 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.