diff --git a/app/api/workflows/sync/route.ts b/app/api/workflows/sync/route.ts new file mode 100644 index 000000000..323afca10 --- /dev/null +++ b/app/api/workflows/sync/route.ts @@ -0,0 +1,67 @@ +import { NextResponse } from 'next/server' +import { eq } from 'drizzle-orm' +import { z } from 'zod' +import { getSession } from '@/lib/auth' +import { db } from '@/db' +import { workflow } from '@/db/schema' + +// Define the schema for incoming data +const WorkflowSyncSchema = z.object({ + id: z.string(), + name: z.string(), + description: z.string().optional(), + state: z.string(), // JSON stringified workflow state +}) + +export async function POST(request: Request) { + try { + // Get the authenticated user + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + // Parse and validate the request body + const body = await request.json() + const { id, name, description, state } = WorkflowSyncSchema.parse(body) + + // Get the current timestamp + const now = new Date() + + // Upsert the workflow + await db + .insert(workflow) + .values({ + id, + userId: session.user.id, + name, + description, + state, + lastSynced: now, + createdAt: now, + updatedAt: now, + }) + .onConflictDoUpdate({ + target: [workflow.id], + set: { + name, + description, + state, + lastSynced: now, + updatedAt: now, + }, + where: eq(workflow.userId, session.user.id), // Only update if the workflow belongs to the user + }) + + return NextResponse.json({ success: true }) + } catch (error) { + console.error('Workflow sync error:', error) + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: 'Invalid request data', details: error.errors }, + { status: 400 } + ) + } + return NextResponse.json({ error: 'Sync failed' }, { status: 500 }) + } +} diff --git a/app/w/[id]/workflow.tsx b/app/w/[id]/workflow.tsx index 224a2534e..5944c2ad0 100644 --- a/app/w/[id]/workflow.tsx +++ b/app/w/[id]/workflow.tsx @@ -19,6 +19,7 @@ import { initializeStateLogger } from '@/stores/workflow/logger' import { useWorkflowRegistry } from '@/stores/workflow/registry/store' import { useWorkflowStore } from '@/stores/workflow/store' import { NotificationList } from '@/app/w/components/notifications/notifications' +import { WorkflowSyncWrapper } from '@/app/w/components/workflows/sync-wrapper' import { getBlock } from '../../../blocks' import { ErrorBoundary } from '../components/error-boundary/error-boundary' import { CustomEdge } from './components/custom-edge/custom-edge' @@ -378,7 +379,9 @@ export default function Workflow() { return ( - + + + ) diff --git a/app/w/components/workflows/sync-wrapper.tsx b/app/w/components/workflows/sync-wrapper.tsx new file mode 100644 index 000000000..0428922e5 --- /dev/null +++ b/app/w/components/workflows/sync-wrapper.tsx @@ -0,0 +1,18 @@ +import { ReactNode } from 'react' +import { + useDebouncedWorkflowSync, + usePeriodicWorkflowSync, + useSyncOnUnload, +} from '@/stores/workflow/sync/hooks' + +interface WorkflowSyncWrapperProps { + children: ReactNode +} + +export function WorkflowSyncWrapper({ children }: WorkflowSyncWrapperProps) { + useDebouncedWorkflowSync() + usePeriodicWorkflowSync() + useSyncOnUnload() + + return <>{children} +} diff --git a/package-lock.json b/package-lock.json index df967315c..0e1a21d1a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,12 +21,14 @@ "@radix-ui/react-switch": "^1.1.2", "@radix-ui/react-tabs": "^1.1.2", "@radix-ui/react-tooltip": "^1.1.6", + "@types/lodash.debounce": "^4.0.9", "better-auth": "^1.1.18", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.0.0", "date-fns": "^4.1.0", "drizzle-orm": "^0.39.3", + "lodash.debounce": "^4.0.8", "lucide-react": "^0.469.0", "next": "15.1.3", "openai": "^4.83.0", @@ -4512,6 +4514,21 @@ "pretty-format": "^29.0.0" } }, + "node_modules/@types/lodash": { + "version": "4.17.15", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.15.tgz", + "integrity": "sha512-w/P33JFeySuhN6JLkysYUK2gEmy9kHHFN7E8ro0tkfmlDOgxBDzWEZ/J8cWA+fHqFevpswDTFZnDx+R9lbL6xw==", + "license": "MIT" + }, + "node_modules/@types/lodash.debounce": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/lodash.debounce/-/lodash.debounce-4.0.9.tgz", + "integrity": "sha512-Ma5JcgTREwpLRwMM+XwBR7DaWe96nC38uCBDFKZWbNKD+osjVzdpnUSwBcqCptrp16sSOLBAUb50Car5I0TCsQ==", + "license": "MIT", + "dependencies": { + "@types/lodash": "*" + } + }, "node_modules/@types/node": { "version": "20.17.11", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.11.tgz", @@ -8513,6 +8530,12 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.debounce": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", + "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", + "license": "MIT" + }, "node_modules/lodash.memoize": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", diff --git a/package.json b/package.json index 291f34c79..4b3f5ef1a 100644 --- a/package.json +++ b/package.json @@ -29,12 +29,14 @@ "@radix-ui/react-switch": "^1.1.2", "@radix-ui/react-tabs": "^1.1.2", "@radix-ui/react-tooltip": "^1.1.6", + "@types/lodash.debounce": "^4.0.9", "better-auth": "^1.1.18", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.0.0", "date-fns": "^4.1.0", "drizzle-orm": "^0.39.3", + "lodash.debounce": "^4.0.8", "lucide-react": "^0.469.0", "next": "15.1.3", "openai": "^4.83.0", diff --git a/stores/workflow/sync/hooks.ts b/stores/workflow/sync/hooks.ts new file mode 100644 index 000000000..101cf06b9 --- /dev/null +++ b/stores/workflow/sync/hooks.ts @@ -0,0 +1,143 @@ +import { useCallback, useEffect, useRef } from 'react' +import debounce from 'lodash.debounce' +import { useWorkflowRegistry } from '../registry/store' +import { useWorkflowStore } from '../store' + +const SYNC_DEBOUNCE_MS = 2000 // 2 seconds +const PERIODIC_SYNC_MS = 30000 // 30 seconds + +interface SyncPayload { + id: string + name: string + description?: string + state: string +} + +async function syncWorkflowToServer(payload: SyncPayload): Promise { + try { + const response = await fetch('/api/workflows/sync', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }) + + if (!response.ok) { + throw new Error(`Sync failed: ${response.statusText}`) + } + + return true + } catch (error) { + console.error('Error syncing workflow:', error) + return false + } +} + +export function useDebouncedWorkflowSync() { + const workflowState = useWorkflowStore((state) => ({ + blocks: state.blocks, + edges: state.edges, + loops: state.loops, + lastSaved: state.lastSaved, + })) + const { activeWorkflowId, workflows } = useWorkflowRegistry() + + const debouncedSyncRef = useRef | null>(null) + + useEffect(() => { + if (!activeWorkflowId || !workflows[activeWorkflowId]) return + + const syncWorkflow = async () => { + const activeWorkflow = workflows[activeWorkflowId] + const payload: SyncPayload = { + id: activeWorkflowId, + name: activeWorkflow.name, + description: activeWorkflow.description, + state: JSON.stringify(workflowState), + } + + await syncWorkflowToServer(payload) + } + + // Create a debounced version of syncWorkflow + if (!debouncedSyncRef.current) { + debouncedSyncRef.current = debounce(syncWorkflow, SYNC_DEBOUNCE_MS) + } + + // Call the debounced sync + debouncedSyncRef.current() + + // Cleanup + return () => { + debouncedSyncRef.current?.cancel() + } + }, [activeWorkflowId, workflows, workflowState]) +} + +export function usePeriodicWorkflowSync() { + const workflowState = useWorkflowStore((state) => ({ + blocks: state.blocks, + edges: state.edges, + loops: state.loops, + lastSaved: state.lastSaved, + })) + const { activeWorkflowId, workflows } = useWorkflowRegistry() + + useEffect(() => { + if (!activeWorkflowId || !workflows[activeWorkflowId]) return + + const syncWorkflow = async () => { + const activeWorkflow = workflows[activeWorkflowId] + const payload: SyncPayload = { + id: activeWorkflowId, + name: activeWorkflow.name, + description: activeWorkflow.description, + state: JSON.stringify(workflowState), + } + + await syncWorkflowToServer(payload) + } + + const intervalId = setInterval(syncWorkflow, PERIODIC_SYNC_MS) + + return () => clearInterval(intervalId) + }, [activeWorkflowId, workflows, workflowState]) +} + +export function useSyncOnUnload() { + const workflowState = useWorkflowStore((state) => ({ + blocks: state.blocks, + edges: state.edges, + loops: state.loops, + lastSaved: state.lastSaved, + })) + const { activeWorkflowId, workflows } = useWorkflowRegistry() + + useEffect(() => { + if (!activeWorkflowId || !workflows[activeWorkflowId]) return + + const handleBeforeUnload = async (event: BeforeUnloadEvent) => { + const activeWorkflow = workflows[activeWorkflowId] + const payload: SyncPayload = { + id: activeWorkflowId, + name: activeWorkflow.name, + description: activeWorkflow.description, + state: JSON.stringify(workflowState), + } + + // Use the keepalive option to try to complete the request even during unload + await fetch('/api/workflows/sync', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + keepalive: true, + }) + + // Show a confirmation dialog + event.preventDefault() + event.returnValue = '' + } + + window.addEventListener('beforeunload', handleBeforeUnload) + return () => window.removeEventListener('beforeunload', handleBeforeUnload) + }, [activeWorkflowId, workflows, workflowState]) +}