feat: [US-045] - Supabase Realtime channel and connection management

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Gustavo Henrique Santos Souza de Miranda 2026-01-23 15:28:23 -03:00
parent dfbaa0066d
commit 2e313a0264
5 changed files with 221 additions and 2 deletions

View File

@ -1,6 +1,6 @@
'use client' 'use client'
import React, { useCallback, useMemo, useState } from 'react' import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import ReactFlow, { import ReactFlow, {
Background, Background,
BackgroundVariant, BackgroundVariant,
@ -27,10 +27,12 @@ import ConditionEditor from '@/components/editor/ConditionEditor'
import ExportValidationModal, { type ValidationIssue } from '@/components/editor/ExportValidationModal' import ExportValidationModal, { type ValidationIssue } from '@/components/editor/ExportValidationModal'
import { EditorProvider } from '@/components/editor/EditorContext' import { EditorProvider } from '@/components/editor/EditorContext'
import Toast from '@/components/Toast' import Toast from '@/components/Toast'
import { RealtimeConnection, type ConnectionState } from '@/lib/collaboration/realtime'
import type { FlowchartData, FlowchartNode, FlowchartEdge, Character, Variable, Condition } from '@/types/flowchart' import type { FlowchartData, FlowchartNode, FlowchartEdge, Character, Variable, Condition } from '@/types/flowchart'
type FlowchartEditorProps = { type FlowchartEditorProps = {
projectId: string projectId: string
userId: string
initialData: FlowchartData initialData: FlowchartData
needsMigration?: boolean needsMigration?: boolean
} }
@ -202,7 +204,7 @@ function computeMigration(initialData: FlowchartData, shouldMigrate: boolean) {
} }
// Inner component that uses useReactFlow hook // Inner component that uses useReactFlow hook
function FlowchartEditorInner({ projectId, initialData, needsMigration }: FlowchartEditorProps) { function FlowchartEditorInner({ projectId, userId, initialData, needsMigration }: FlowchartEditorProps) {
// Define custom node types - memoized to prevent re-renders // Define custom node types - memoized to prevent re-renders
const nodeTypes: NodeTypes = useMemo( const nodeTypes: NodeTypes = useMemo(
() => ({ () => ({
@ -232,6 +234,22 @@ function FlowchartEditorInner({ projectId, initialData, needsMigration }: Flowch
const [toastMessage, setToastMessage] = useState<string | null>(migratedData.toastMessage) const [toastMessage, setToastMessage] = useState<string | null>(migratedData.toastMessage)
const [validationIssues, setValidationIssues] = useState<ValidationIssue[] | null>(null) const [validationIssues, setValidationIssues] = useState<ValidationIssue[] | null>(null)
const [warningNodeIds, setWarningNodeIds] = useState<Set<string>>(new Set()) const [warningNodeIds, setWarningNodeIds] = useState<Set<string>>(new Set())
const [connectionState, setConnectionState] = useState<ConnectionState>('disconnected')
const realtimeRef = useRef<RealtimeConnection | null>(null)
// Connect to Supabase Realtime channel on mount, disconnect on unmount
useEffect(() => {
const connection = new RealtimeConnection(projectId, userId, {
onConnectionStateChange: setConnectionState,
})
realtimeRef.current = connection
connection.connect()
return () => {
connection.disconnect()
realtimeRef.current = null
}
}, [projectId, userId])
const handleAddCharacter = useCallback( const handleAddCharacter = useCallback(
(name: string, color: string): string => { (name: string, color: string): string => {
@ -511,6 +529,7 @@ function FlowchartEditorInner({ projectId, initialData, needsMigration }: Flowch
onExport={handleExport} onExport={handleExport}
onImport={handleImport} onImport={handleImport}
onProjectSettings={() => setShowSettings(true)} onProjectSettings={() => setShowSettings(true)}
connectionState={connectionState}
/> />
<div className="flex-1"> <div className="flex-1">
<ReactFlow <ReactFlow

View File

@ -73,6 +73,7 @@ export default async function EditorPage({ params }: PageProps) {
<div className="flex-1"> <div className="flex-1">
<FlowchartEditor <FlowchartEditor
projectId={project.id} projectId={project.id}
userId={user.id}
initialData={flowchartData} initialData={flowchartData}
needsMigration={needsMigration} needsMigration={needsMigration}
/> />

View File

@ -1,5 +1,7 @@
'use client' 'use client'
import type { ConnectionState } from '@/lib/collaboration/realtime'
type ToolbarProps = { type ToolbarProps = {
onAddDialogue: () => void onAddDialogue: () => void
onAddChoice: () => void onAddChoice: () => void
@ -8,6 +10,21 @@ type ToolbarProps = {
onExport: () => void onExport: () => void
onImport: () => void onImport: () => void
onProjectSettings: () => void onProjectSettings: () => void
connectionState?: ConnectionState
}
const connectionLabel: Record<ConnectionState, string> = {
connecting: 'Connecting…',
connected: 'Connected',
disconnected: 'Disconnected',
reconnecting: 'Reconnecting…',
}
const connectionColor: Record<ConnectionState, string> = {
connecting: 'bg-yellow-400',
connected: 'bg-green-400',
disconnected: 'bg-red-400',
reconnecting: 'bg-yellow-400',
} }
export default function Toolbar({ export default function Toolbar({
@ -18,6 +35,7 @@ export default function Toolbar({
onExport, onExport,
onImport, onImport,
onProjectSettings, onProjectSettings,
connectionState,
}: ToolbarProps) { }: ToolbarProps) {
return ( return (
<div className="flex items-center justify-between border-b border-zinc-200 bg-zinc-50 px-4 py-2 dark:border-zinc-700 dark:bg-zinc-800"> <div className="flex items-center justify-between border-b border-zinc-200 bg-zinc-50 px-4 py-2 dark:border-zinc-700 dark:bg-zinc-800">
@ -46,6 +64,14 @@ export default function Toolbar({
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{connectionState && (
<div className="flex items-center gap-1.5 mr-2" title={connectionLabel[connectionState]}>
<span className={`inline-block h-2.5 w-2.5 rounded-full ${connectionColor[connectionState]}${connectionState === 'reconnecting' ? ' animate-pulse' : ''}`} />
<span className="text-xs text-zinc-500 dark:text-zinc-400">
{connectionLabel[connectionState]}
</span>
</div>
)}
<button <button
onClick={onProjectSettings} onClick={onProjectSettings}
className="rounded border border-zinc-300 bg-white px-3 py-1.5 text-sm font-medium text-zinc-700 hover:bg-zinc-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:border-zinc-600 dark:bg-zinc-700 dark:text-zinc-200 dark:hover:bg-zinc-600 dark:focus:ring-offset-zinc-800" className="rounded border border-zinc-300 bg-white px-3 py-1.5 text-sm font-medium text-zinc-700 hover:bg-zinc-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:border-zinc-600 dark:bg-zinc-700 dark:text-zinc-200 dark:hover:bg-zinc-600 dark:focus:ring-offset-zinc-800"

View File

@ -0,0 +1,167 @@
import { createClient } from '@/lib/supabase/client'
import type { RealtimeChannel } from '@supabase/supabase-js'
export type ConnectionState = 'connecting' | 'connected' | 'disconnected' | 'reconnecting'
type RealtimeCallbacks = {
onConnectionStateChange: (state: ConnectionState) => void
onPresenceSync?: (presences: Record<string, unknown[]>) => void
}
const HEARTBEAT_INTERVAL_MS = 30_000
const RECONNECT_BASE_DELAY_MS = 1000
const RECONNECT_MAX_DELAY_MS = 30_000
export class RealtimeConnection {
private channel: RealtimeChannel | null = null
private heartbeatTimer: ReturnType<typeof setInterval> | null = null
private reconnectTimer: ReturnType<typeof setTimeout> | null = null
private reconnectAttempts = 0
private projectId: string
private userId: string
private callbacks: RealtimeCallbacks
private isDestroyed = false
private supabase = createClient()
constructor(projectId: string, userId: string, callbacks: RealtimeCallbacks) {
this.projectId = projectId
this.userId = userId
this.callbacks = callbacks
}
async connect(): Promise<void> {
if (this.isDestroyed) return
this.callbacks.onConnectionStateChange('connecting')
this.channel = this.supabase.channel(`project:${this.projectId}`, {
config: { presence: { key: this.userId } },
})
this.channel
.on('presence', { event: 'sync' }, () => {
if (this.channel) {
const state = this.channel.presenceState()
this.callbacks.onPresenceSync?.(state)
}
})
.subscribe(async (status) => {
if (this.isDestroyed) return
if (status === 'SUBSCRIBED') {
this.reconnectAttempts = 0
this.callbacks.onConnectionStateChange('connected')
this.startHeartbeat()
await this.createSession()
} else if (status === 'CHANNEL_ERROR' || status === 'TIMED_OUT') {
this.callbacks.onConnectionStateChange('reconnecting')
this.scheduleReconnect()
} else if (status === 'CLOSED') {
this.callbacks.onConnectionStateChange('disconnected')
}
})
}
async disconnect(): Promise<void> {
this.isDestroyed = true
this.stopHeartbeat()
this.clearReconnectTimer()
if (this.channel) {
await this.deleteSession()
this.supabase.removeChannel(this.channel)
this.channel = null
}
this.callbacks.onConnectionStateChange('disconnected')
}
getChannel(): RealtimeChannel | null {
return this.channel
}
private async createSession(): Promise<void> {
try {
await this.supabase.from('collaboration_sessions').upsert(
{
project_id: this.projectId,
user_id: this.userId,
cursor_position: null,
selected_node_id: null,
connected_at: new Date().toISOString(),
last_heartbeat: new Date().toISOString(),
},
{ onConflict: 'project_id,user_id' }
)
} catch {
// Fire-and-forget: session creation failure shouldn't block editing
}
}
private async deleteSession(): Promise<void> {
try {
await this.supabase
.from('collaboration_sessions')
.delete()
.eq('project_id', this.projectId)
.eq('user_id', this.userId)
} catch {
// Fire-and-forget: cleanup failure is non-critical
}
}
private startHeartbeat(): void {
this.stopHeartbeat()
this.heartbeatTimer = setInterval(async () => {
if (this.isDestroyed) {
this.stopHeartbeat()
return
}
try {
await this.supabase
.from('collaboration_sessions')
.update({ last_heartbeat: new Date().toISOString() })
.eq('project_id', this.projectId)
.eq('user_id', this.userId)
} catch {
// Heartbeat failure is non-critical
}
}, HEARTBEAT_INTERVAL_MS)
}
private stopHeartbeat(): void {
if (this.heartbeatTimer) {
clearInterval(this.heartbeatTimer)
this.heartbeatTimer = null
}
}
private scheduleReconnect(): void {
if (this.isDestroyed) return
this.clearReconnectTimer()
const delay = Math.min(
RECONNECT_BASE_DELAY_MS * Math.pow(2, this.reconnectAttempts),
RECONNECT_MAX_DELAY_MS
)
this.reconnectAttempts++
this.reconnectTimer = setTimeout(async () => {
if (this.isDestroyed) return
this.callbacks.onConnectionStateChange('reconnecting')
if (this.channel) {
this.supabase.removeChannel(this.channel)
this.channel = null
}
await this.connect()
}, delay)
}
private clearReconnectTimer(): void {
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer)
this.reconnectTimer = null
}
}
}

View File

@ -0,0 +1,6 @@
-- Add unique constraint on collaboration_sessions for (project_id, user_id)
-- This ensures a user can only have one active session per project,
-- and allows upsert operations for session management.
ALTER TABLE collaboration_sessions
ADD CONSTRAINT collaboration_sessions_project_user_unique
UNIQUE (project_id, user_id);