feat: [US-045] - Supabase Realtime channel and connection management
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
dfbaa0066d
commit
2e313a0264
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
Loading…
Reference in New Issue