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:
| 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:
import {
useRealtimeSubscription,
useRealtimeBroadcast,
useRealtimePresence,
RealtimeConnectionBadge,
PresenceAvatars,
} from '@/platform/realtime';
Migration Principles
- Internal refactors only — External hook APIs (return types, option types) MUST NOT change
- Zero regressions — Run existing tests before and after each migration
- One hook at a time — Migrate and verify each hook separately, commit after each
- Core ownership — PF team migrates PF hooks; FW/CE teams migrate their own hooks
- 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).
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 identical — BedCensusWidget, EmployeeCountWidget, and all other consumers work unchanged.
Verification
npm run typecheck
npm run build
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
| 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
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
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 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.