feat(dbsync): added workflow sync wrapper to workflow, combine debounce, period, and unload syncing to sync local state store to workflow pg table

This commit is contained in:
Waleed Latif
2025-02-16 22:39:26 -08:00
parent ddd2aa5d0e
commit 763ea8bb5c
6 changed files with 257 additions and 1 deletions
+67
View File
@@ -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 })
}
}
+4 -1
View File
@@ -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 (
<ReactFlowProvider>
<ErrorBoundary>
<WorkflowContent />
<WorkflowSyncWrapper>
<WorkflowContent />
</WorkflowSyncWrapper>
</ErrorBoundary>
</ReactFlowProvider>
)
@@ -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}</>
}
+23
View File
@@ -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",
+2
View File
@@ -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",
+143
View File
@@ -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<boolean> {
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<ReturnType<typeof debounce> | 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])
}