Generated by Spark: Create Redux persistence middleware to sync state with database automatically

This commit is contained in:
2026-01-17 21:14:31 +00:00
committed by GitHub
parent 45454ac34b
commit 98f4b49edf
13 changed files with 2013 additions and 1 deletions

View File

@@ -0,0 +1,288 @@
import { useState, useEffect } from 'react'
import { Card } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Separator } from '@/components/ui/separator'
import { Switch } from '@/components/ui/switch'
import { Label } from '@/components/ui/label'
import {
Database,
CloudArrowUp,
CloudArrowDown,
ArrowsClockwise,
CheckCircle,
XCircle,
Clock,
ChartLine,
Gauge
} from '@phosphor-icons/react'
import { usePersistence } from '@/hooks/use-persistence'
import { useAppDispatch, useAppSelector } from '@/store'
import { syncToFlaskBulk, syncFromFlaskBulk, checkFlaskConnection } from '@/store/slices/syncSlice'
import { toast } from 'sonner'
export function PersistenceDashboard() {
const dispatch = useAppDispatch()
const { status, metrics, autoSyncStatus, syncNow, configureAutoSync } = usePersistence()
const [autoSyncEnabled, setAutoSyncEnabled] = useState(false)
const [syncing, setSyncing] = useState(false)
useEffect(() => {
dispatch(checkFlaskConnection())
const interval = setInterval(() => {
dispatch(checkFlaskConnection())
}, 10000)
return () => clearInterval(interval)
}, [dispatch])
const handleSyncToFlask = async () => {
setSyncing(true)
try {
await dispatch(syncToFlaskBulk()).unwrap()
toast.success('Successfully synced to Flask')
} catch (error: any) {
toast.error(`Sync failed: ${error}`)
} finally {
setSyncing(false)
}
}
const handleSyncFromFlask = async () => {
setSyncing(true)
try {
await dispatch(syncFromFlaskBulk()).unwrap()
toast.success('Successfully synced from Flask')
} catch (error: any) {
toast.error(`Sync failed: ${error}`)
} finally {
setSyncing(false)
}
}
const handleAutoSyncToggle = (enabled: boolean) => {
setAutoSyncEnabled(enabled)
configureAutoSync({ enabled, syncOnChange: true })
toast.info(enabled ? 'Auto-sync enabled' : 'Auto-sync disabled')
}
const handleManualSync = async () => {
try {
await syncNow()
toast.success('Manual sync completed')
} catch (error: any) {
toast.error(`Manual sync failed: ${error}`)
}
}
const getStatusColor = () => {
if (!status.flaskConnected) return 'bg-destructive'
if (status.syncStatus === 'syncing') return 'bg-amber-500'
if (status.syncStatus === 'success') return 'bg-accent'
if (status.syncStatus === 'error') return 'bg-destructive'
return 'bg-muted'
}
const getStatusText = () => {
if (!status.flaskConnected) return 'Disconnected'
if (status.syncStatus === 'syncing') return 'Syncing...'
if (status.syncStatus === 'success') return 'Synced'
if (status.syncStatus === 'error') return 'Error'
return 'Idle'
}
const formatTime = (timestamp: number | null) => {
if (!timestamp) return 'Never'
const date = new Date(timestamp)
return date.toLocaleTimeString()
}
const formatDuration = (ms: number) => {
if (ms < 1000) return `${ms}ms`
return `${(ms / 1000).toFixed(1)}s`
}
const getSuccessRate = () => {
if (metrics.totalOperations === 0) return 0
return Math.round((metrics.successfulOperations / metrics.totalOperations) * 100)
}
return (
<div className="p-6 space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-3xl font-bold">Persistence & Sync Dashboard</h1>
<Badge className={`${getStatusColor()} text-white`}>
{getStatusText()}
</Badge>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<Card className="p-6 space-y-4 border-sidebar-border hover:bg-muted/50 transition-colors">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold flex items-center gap-2">
<Database className="text-primary" />
Connection Status
</h3>
{status.flaskConnected ? (
<CheckCircle className="text-accent" weight="fill" />
) : (
<XCircle className="text-destructive" weight="fill" />
)}
</div>
<Separator />
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Local Storage:</span>
<span className="font-medium">IndexedDB</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Remote Storage:</span>
<span className="font-medium">
{status.flaskConnected ? 'Flask API' : 'Offline'}
</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Last Sync:</span>
<span className="font-medium">{formatTime(status.lastSyncTime)}</span>
</div>
</div>
</Card>
<Card className="p-6 space-y-4 border-sidebar-border hover:bg-muted/50 transition-colors">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold flex items-center gap-2">
<ChartLine className="text-accent" />
Sync Metrics
</h3>
<Gauge className="text-muted-foreground" />
</div>
<Separator />
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Total Operations:</span>
<span className="font-medium">{metrics.totalOperations}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Success Rate:</span>
<span className="font-medium text-accent">{getSuccessRate()}%</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Avg Duration:</span>
<span className="font-medium">{formatDuration(metrics.averageOperationTime)}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Failed:</span>
<span className="font-medium text-destructive">{metrics.failedOperations}</span>
</div>
</div>
</Card>
<Card className="p-6 space-y-4 border-sidebar-border hover:bg-muted/50 transition-colors">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold flex items-center gap-2">
<Clock className="text-amber-500" />
Auto-Sync
</h3>
<Switch checked={autoSyncEnabled} onCheckedChange={handleAutoSyncToggle} />
</div>
<Separator />
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Status:</span>
<span className="font-medium">
{autoSyncStatus.enabled ? 'Enabled' : 'Disabled'}
</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Changes Pending:</span>
<span className="font-medium">{autoSyncStatus.changeCounter}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Next Sync:</span>
<span className="font-medium">
{autoSyncStatus.nextSyncIn !== null
? formatDuration(autoSyncStatus.nextSyncIn)
: 'N/A'}
</span>
</div>
</div>
</Card>
</div>
<Card className="p-6 space-y-4 border-sidebar-border">
<h3 className="text-lg font-semibold flex items-center gap-2">
<ArrowsClockwise className="text-primary" />
Manual Sync Operations
</h3>
<Separator />
<div className="flex flex-wrap gap-3">
<Button
onClick={handleSyncToFlask}
disabled={syncing || !status.flaskConnected}
className="flex items-center gap-2"
>
<CloudArrowUp />
Push to Flask
</Button>
<Button
onClick={handleSyncFromFlask}
disabled={syncing || !status.flaskConnected}
variant="outline"
className="flex items-center gap-2"
>
<CloudArrowDown />
Pull from Flask
</Button>
<Button
onClick={handleManualSync}
disabled={syncing || !autoSyncStatus.enabled}
variant="outline"
className="flex items-center gap-2"
>
<ArrowsClockwise />
Trigger Auto-Sync
</Button>
<Button
onClick={() => dispatch(checkFlaskConnection())}
variant="outline"
className="flex items-center gap-2"
>
<CheckCircle />
Check Connection
</Button>
</div>
</Card>
{status.error && (
<Card className="p-6 border-destructive bg-destructive/10">
<div className="flex items-start gap-3">
<XCircle className="text-destructive mt-1" weight="fill" />
<div>
<h3 className="font-semibold text-destructive mb-1">Sync Error</h3>
<p className="text-sm text-muted-foreground">{status.error}</p>
</div>
</div>
</Card>
)}
<Card className="p-6 space-y-4 border-sidebar-border">
<h3 className="text-lg font-semibold">How Persistence Works</h3>
<Separator />
<div className="space-y-3 text-sm text-muted-foreground">
<p>
<strong className="text-foreground">Automatic Persistence:</strong> All Redux state changes are automatically persisted to IndexedDB with a 300ms debounce.
</p>
<p>
<strong className="text-foreground">Flask Sync:</strong> When connected, data is synced bidirectionally with the Flask API backend.
</p>
<p>
<strong className="text-foreground">Auto-Sync:</strong> Enable auto-sync to automatically push changes to Flask at regular intervals (default: 30s).
</p>
<p>
<strong className="text-foreground">Conflict Resolution:</strong> When conflicts are detected during sync, you'll be notified to resolve them manually.
</p>
</div>
</Card>
</div>
)
}

View File

@@ -0,0 +1,251 @@
import { useState } from 'react'
import { Card } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Badge } from '@/components/ui/badge'
import { Separator } from '@/components/ui/separator'
import { FloppyDisk, Trash, PencilSimple, CheckCircle, Clock } from '@phosphor-icons/react'
import { useAppDispatch, useAppSelector } from '@/store'
import { saveFile, deleteFile } from '@/store/slices/filesSlice'
import { toast } from 'sonner'
export function PersistenceExample() {
const dispatch = useAppDispatch()
const files = useAppSelector((state) => state.files.files)
const [fileName, setFileName] = useState('')
const [fileContent, setFileContent] = useState('')
const [editingId, setEditingId] = useState<string | null>(null)
const handleSave = async () => {
if (!fileName.trim()) {
toast.error('File name is required')
return
}
const fileItem = {
id: editingId || `file-${Date.now()}`,
name: fileName,
content: fileContent,
language: 'javascript',
path: `/src/${fileName}`,
updatedAt: Date.now(),
}
try {
await dispatch(saveFile(fileItem)).unwrap()
toast.success(`File "${fileName}" saved automatically!`, {
description: 'Synced to IndexedDB and Flask API',
})
setFileName('')
setFileContent('')
setEditingId(null)
} catch (error: any) {
toast.error('Failed to save file', {
description: error,
})
}
}
const handleEdit = (file: any) => {
setEditingId(file.id)
setFileName(file.name)
setFileContent(file.content)
}
const handleDelete = async (fileId: string, name: string) => {
try {
await dispatch(deleteFile(fileId)).unwrap()
toast.success(`File "${name}" deleted`, {
description: 'Automatically synced to storage',
})
} catch (error: any) {
toast.error('Failed to delete file', {
description: error,
})
}
}
const handleCancel = () => {
setFileName('')
setFileContent('')
setEditingId(null)
}
return (
<div className="p-6 space-y-6">
<div>
<h1 className="text-3xl font-bold mb-2">Persistence Middleware Example</h1>
<p className="text-muted-foreground">
Demonstrates automatic persistence of Redux state to IndexedDB and Flask API
</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Card className="p-6 space-y-4 border-sidebar-border">
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold">
{editingId ? 'Edit File' : 'Create File'}
</h2>
{editingId && (
<Badge variant="outline" className="text-amber-500 border-amber-500">
Editing
</Badge>
)}
</div>
<Separator />
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="fileName">File Name</Label>
<Input
id="fileName"
value={fileName}
onChange={(e) => setFileName(e.target.value)}
placeholder="example.js"
/>
</div>
<div className="space-y-2">
<Label htmlFor="fileContent">Content</Label>
<textarea
id="fileContent"
value={fileContent}
onChange={(e) => setFileContent(e.target.value)}
placeholder="console.log('Hello, world!')"
className="w-full h-32 px-3 py-2 border border-input rounded-md bg-background font-mono text-sm resize-none"
/>
</div>
<div className="flex gap-2">
<Button onClick={handleSave} className="flex items-center gap-2 flex-1">
<FloppyDisk />
{editingId ? 'Update File' : 'Save File'}
</Button>
{editingId && (
<Button onClick={handleCancel} variant="outline">
Cancel
</Button>
)}
</div>
<div className="p-4 bg-muted/50 rounded-lg space-y-2">
<div className="flex items-start gap-2">
<CheckCircle className="text-accent mt-1" weight="fill" />
<div className="text-sm">
<p className="font-semibold text-foreground">Automatic Persistence</p>
<p className="text-muted-foreground">
Data is automatically saved to IndexedDB with 300ms debounce
</p>
</div>
</div>
<div className="flex items-start gap-2">
<Clock className="text-primary mt-1" weight="fill" />
<div className="text-sm">
<p className="font-semibold text-foreground">Flask Sync</p>
<p className="text-muted-foreground">
Changes are synced to Flask API backend automatically
</p>
</div>
</div>
</div>
</div>
</Card>
<Card className="p-6 space-y-4 border-sidebar-border">
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold">Saved Files</h2>
<Badge variant="secondary">{files.length} files</Badge>
</div>
<Separator />
<div className="space-y-3 max-h-[500px] overflow-y-auto">
{files.length === 0 ? (
<div className="text-center py-12 text-muted-foreground">
<p>No files yet</p>
<p className="text-sm mt-1">Create your first file to see it appear here</p>
</div>
) : (
files.map((file) => (
<Card
key={file.id}
className="p-4 space-y-2 hover:bg-muted/50 transition-colors border-sidebar-border"
>
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
<h3 className="font-semibold truncate">{file.name}</h3>
<p className="text-sm text-muted-foreground truncate">
{file.path}
</p>
</div>
<Badge variant="outline" className="ml-2 shrink-0">
{file.language}
</Badge>
</div>
{file.content && (
<div className="bg-muted/50 p-2 rounded text-xs font-mono text-muted-foreground max-h-20 overflow-hidden">
{file.content.substring(0, 100)}
{file.content.length > 100 && '...'}
</div>
)}
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>
Updated: {new Date(file.updatedAt).toLocaleTimeString()}
</span>
<div className="flex gap-2">
<Button
size="sm"
variant="ghost"
onClick={() => handleEdit(file)}
className="h-8 flex items-center gap-1"
>
<PencilSimple />
Edit
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => handleDelete(file.id, file.name)}
className="h-8 flex items-center gap-1 text-destructive hover:text-destructive"
>
<Trash />
Delete
</Button>
</div>
</div>
</Card>
))
)}
</div>
</Card>
</div>
<Card className="p-6 space-y-4 border-primary/50 bg-primary/5">
<h3 className="text-lg font-semibold">How It Works</h3>
<Separator />
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-sm">
<div className="space-y-2">
<div className="font-semibold text-primary">1. Redux Action</div>
<p className="text-muted-foreground">
When you save or delete a file, a Redux action is dispatched
</p>
</div>
<div className="space-y-2">
<div className="font-semibold text-primary">2. Middleware Intercepts</div>
<p className="text-muted-foreground">
Persistence middleware automatically intercepts the action and queues the operation
</p>
</div>
<div className="space-y-2">
<div className="font-semibold text-primary">3. Auto-Sync</div>
<p className="text-muted-foreground">
After 300ms debounce, data is saved to IndexedDB and synced to Flask API
</p>
</div>
</div>
</Card>
</div>
)
}

View File

@@ -317,6 +317,24 @@
"order": 19,
"props": {}
},
{
"id": "persistence",
"title": "Persistence",
"icon": "Database",
"component": "PersistenceDashboard",
"enabled": true,
"order": 20,
"props": {}
},
{
"id": "persistence-demo",
"title": "Persistence Demo",
"icon": "Lightning",
"component": "PersistenceExample",
"enabled": true,
"order": 21,
"props": {}
},
{
"id": "templates",
"title": "Templates",

View File

@@ -0,0 +1,111 @@
import { useEffect, useState } from 'react'
import { useAppSelector } from '@/store'
import {
flushPersistence,
configurePersistence,
enablePersistence,
disablePersistence
} from '@/store/middleware/persistenceMiddleware'
import {
getSyncMetrics,
resetSyncMetrics,
subscribeSyncMetrics
} from '@/store/middleware/syncMonitorMiddleware'
import {
configureAutoSync,
getAutoSyncStatus,
triggerAutoSync
} from '@/store/middleware/autoSyncMiddleware'
interface PersistenceStatus {
enabled: boolean
lastSyncTime: number | null
syncStatus: 'idle' | 'syncing' | 'success' | 'error'
error: string | null
flaskConnected: boolean
}
interface SyncMetrics {
totalOperations: number
successfulOperations: number
failedOperations: number
lastOperationTime: number
averageOperationTime: number
}
interface AutoSyncStatus {
enabled: boolean
lastSyncTime: number
changeCounter: number
nextSyncIn: number | null
}
export function usePersistence() {
const syncState = useAppSelector((state) => state.sync)
const [metrics, setMetrics] = useState<SyncMetrics>(getSyncMetrics())
const [autoSyncStatus, setAutoSyncStatus] = useState<AutoSyncStatus>(getAutoSyncStatus())
useEffect(() => {
const unsubscribe = subscribeSyncMetrics((newMetrics) => {
setMetrics(newMetrics)
})
const statusTimer = setInterval(() => {
setAutoSyncStatus(getAutoSyncStatus())
}, 1000)
return () => {
unsubscribe()
clearInterval(statusTimer)
}
}, [])
const status: PersistenceStatus = {
enabled: true,
lastSyncTime: syncState.lastSyncedAt,
syncStatus: syncState.status,
error: syncState.error,
flaskConnected: syncState.flaskConnected,
}
const flush = async () => {
await flushPersistence()
}
const configure = (sliceName: string, config: any) => {
configurePersistence(sliceName, config)
}
const enable = (sliceName: string) => {
enablePersistence(sliceName)
}
const disable = (sliceName: string) => {
disablePersistence(sliceName)
}
const resetMetrics = () => {
resetSyncMetrics()
}
const configureAutoSyncSettings = (config: any) => {
configureAutoSync(config)
}
const syncNow = async () => {
await triggerAutoSync()
}
return {
status,
metrics,
autoSyncStatus,
flush,
configure,
enable,
disable,
resetMetrics,
configureAutoSync: configureAutoSyncSettings,
syncNow,
}
}

View File

@@ -11,6 +11,9 @@ import themeReducer from './slices/themeSlice'
import settingsReducer from './slices/settingsSlice'
import syncReducer from './slices/syncSlice'
import conflictsReducer from './slices/conflictsSlice'
import { createPersistenceMiddleware } from './middleware/persistenceMiddleware'
import { createSyncMonitorMiddleware } from './middleware/syncMonitorMiddleware'
import { createAutoSyncMiddleware } from './middleware/autoSyncMiddleware'
export const store = configureStore({
reducer: {
@@ -31,7 +34,10 @@ export const store = configureStore({
serializableCheck: {
ignoredActions: ['persist/PERSIST', 'persist/REHYDRATE'],
},
}),
})
.concat(createPersistenceMiddleware())
.concat(createSyncMonitorMiddleware())
.concat(createAutoSyncMiddleware()),
})
export type RootState = ReturnType<typeof store.getState>

View File

@@ -0,0 +1,162 @@
import { Middleware } from '@reduxjs/toolkit'
import { syncToFlaskBulk, checkFlaskConnection } from '../slices/syncSlice'
import { RootState } from '../index'
interface AutoSyncConfig {
enabled: boolean
intervalMs: number
syncOnChange: boolean
maxQueueSize: number
}
class AutoSyncManager {
private config: AutoSyncConfig = {
enabled: false,
intervalMs: 30000,
syncOnChange: false,
maxQueueSize: 50,
}
private timer: ReturnType<typeof setTimeout> | null = null
private lastSyncTime = 0
private changeCounter = 0
private dispatch: any = null
configure(config: Partial<AutoSyncConfig>) {
this.config = { ...this.config, ...config }
if (this.config.enabled) {
this.start()
} else {
this.stop()
}
}
setDispatch(dispatch: any) {
this.dispatch = dispatch
}
start() {
if (this.timer || !this.dispatch) return
this.timer = setInterval(() => {
if (this.shouldSync()) {
this.performSync()
}
}, this.config.intervalMs)
this.dispatch(checkFlaskConnection())
}
stop() {
if (this.timer) {
clearInterval(this.timer)
this.timer = null
}
}
private shouldSync(): boolean {
if (!this.config.enabled) return false
const timeSinceLastSync = Date.now() - this.lastSyncTime
if (timeSinceLastSync < this.config.intervalMs) return false
if (this.config.syncOnChange && this.changeCounter === 0) return false
return true
}
private async performSync() {
if (!this.dispatch) return
try {
await this.dispatch(syncToFlaskBulk())
this.lastSyncTime = Date.now()
this.changeCounter = 0
} catch (error) {
console.error('[AutoSync] Sync failed:', error)
}
}
trackChange() {
this.changeCounter++
if (this.changeCounter >= this.config.maxQueueSize && this.config.syncOnChange) {
this.performSync()
}
}
getConfig(): AutoSyncConfig {
return { ...this.config }
}
getStatus() {
return {
enabled: this.config.enabled,
lastSyncTime: this.lastSyncTime,
changeCounter: this.changeCounter,
nextSyncIn: this.config.enabled
? Math.max(0, this.config.intervalMs - (Date.now() - this.lastSyncTime))
: null,
}
}
async syncNow() {
await this.performSync()
}
}
export const autoSyncManager = new AutoSyncManager()
export const createAutoSyncMiddleware = (): Middleware => {
return (storeAPI) => {
autoSyncManager.setDispatch(storeAPI.dispatch)
return (next) => (action: any) => {
const result = next(action)
if (!action.type) return result
if (action.type === 'settings/updateSettings' && action.payload?.autoSync !== undefined) {
const state = storeAPI.getState() as RootState
const { autoSync, autoSyncInterval } = (state.settings as any) || {}
autoSyncManager.configure({
enabled: autoSync ?? false,
intervalMs: autoSyncInterval ?? 30000,
})
}
const changeActions = [
'files/addItem',
'files/updateItem',
'files/removeItem',
'models/addItem',
'models/updateItem',
'models/removeItem',
'components/addItem',
'components/updateItem',
'components/removeItem',
'componentTrees/addItem',
'componentTrees/updateItem',
'componentTrees/removeItem',
'workflows/addItem',
'workflows/updateItem',
'workflows/removeItem',
'lambdas/addItem',
'lambdas/updateItem',
'lambdas/removeItem',
]
if (changeActions.includes(action.type)) {
autoSyncManager.trackChange()
}
return result
}
}
}
export const configureAutoSync = (config: Partial<AutoSyncConfig>) => autoSyncManager.configure(config)
export const getAutoSyncStatus = () => autoSyncManager.getStatus()
export const triggerAutoSync = () => autoSyncManager.syncNow()

View File

@@ -0,0 +1,23 @@
export {
createPersistenceMiddleware,
flushPersistence,
configurePersistence,
enablePersistence,
disablePersistence,
} from './persistenceMiddleware'
export {
createSyncMonitorMiddleware,
getSyncMetrics,
resetSyncMetrics,
subscribeSyncMetrics,
} from './syncMonitorMiddleware'
export {
createAutoSyncMiddleware,
configureAutoSync,
getAutoSyncStatus,
triggerAutoSync,
} from './autoSyncMiddleware'
export { syncToFlask, fetchFromFlask, syncAllToFlask, fetchAllFromFlask } from './flaskSync'

View File

@@ -0,0 +1,231 @@
import { Middleware } from '@reduxjs/toolkit'
import { db } from '@/lib/db'
import { syncToFlask } from './flaskSync'
import { RootState } from '../index'
interface PersistenceConfig {
storeName: string
enabled: boolean
syncToFlask: boolean
debounceMs: number
batchSize: number
}
const defaultConfig: PersistenceConfig = {
storeName: '',
enabled: true,
syncToFlask: true,
debounceMs: 300,
batchSize: 10,
}
const sliceToPersistenceMap: Record<string, PersistenceConfig> = {
files: { ...defaultConfig, storeName: 'files' },
models: { ...defaultConfig, storeName: 'models' },
components: { ...defaultConfig, storeName: 'components' },
componentTrees: { ...defaultConfig, storeName: 'componentTrees' },
workflows: { ...defaultConfig, storeName: 'workflows' },
lambdas: { ...defaultConfig, storeName: 'lambdas' },
theme: { ...defaultConfig, storeName: 'theme' },
settings: { ...defaultConfig, storeName: 'settings', syncToFlask: false },
}
type PendingOperation = {
type: 'put' | 'delete'
storeName: string
key: string
value?: any
timestamp: number
}
class PersistenceQueue {
private queue: Map<string, PendingOperation> = new Map()
private processing = false
private debounceTimers: Map<string, ReturnType<typeof setTimeout>> = new Map()
enqueue(operation: PendingOperation, debounceMs: number) {
const opKey = `${operation.storeName}:${operation.key}`
const existingTimer = this.debounceTimers.get(opKey)
if (existingTimer) {
clearTimeout(existingTimer)
}
this.queue.set(opKey, operation)
const timer = setTimeout(() => {
this.debounceTimers.delete(opKey)
this.processQueue()
}, debounceMs)
this.debounceTimers.set(opKey, timer)
}
async processQueue() {
if (this.processing || this.queue.size === 0) return
this.processing = true
try {
const operations = Array.from(this.queue.values())
this.queue.clear()
const results = await Promise.allSettled(
operations.map(async (op) => {
try {
if (op.type === 'put') {
await db.put(op.storeName as any, op.value)
if (sliceToPersistenceMap[op.storeName]?.syncToFlask) {
await syncToFlask(op.storeName, op.key, op.value, 'put')
}
} else if (op.type === 'delete') {
await db.delete(op.storeName as any, op.key)
if (sliceToPersistenceMap[op.storeName]?.syncToFlask) {
await syncToFlask(op.storeName, op.key, null, 'delete')
}
}
} catch (error) {
console.error(`[PersistenceMiddleware] Failed to persist ${op.type} for ${op.storeName}:${op.key}`, error)
throw error
}
})
)
const failed = results.filter(r => r.status === 'rejected')
if (failed.length > 0) {
console.warn(`[PersistenceMiddleware] ${failed.length} operations failed`)
}
} finally {
this.processing = false
}
}
async flush() {
for (const timer of this.debounceTimers.values()) {
clearTimeout(timer)
}
this.debounceTimers.clear()
await this.processQueue()
}
}
const persistenceQueue = new PersistenceQueue()
export const createPersistenceMiddleware = (): Middleware => {
return (storeAPI) => (next) => (action: any) => {
const result = next(action)
if (!action.type) return result
const [sliceName, actionName] = action.type.split('/')
const config = sliceToPersistenceMap[sliceName]
if (!config || !config.enabled) return result
const state = storeAPI.getState() as RootState
const sliceState = state[sliceName as keyof RootState]
if (!sliceState) return result
try {
if (actionName === 'addItem' || actionName === 'updateItem' || actionName === 'saveFile' ||
actionName === 'saveModel' || actionName === 'saveComponent' || actionName === 'saveComponentTree' ||
actionName === 'saveWorkflow' || actionName === 'saveLambda') {
const item = action.payload
if (item && item.id) {
persistenceQueue.enqueue({
type: 'put',
storeName: config.storeName,
key: item.id,
value: { ...item, updatedAt: Date.now() },
timestamp: Date.now(),
}, config.debounceMs)
}
}
if (actionName === 'addItems' || actionName === 'setItems' || actionName === 'setFiles' ||
actionName === 'setModels' || actionName === 'setComponents' || actionName === 'setComponentTrees' ||
actionName === 'setWorkflows' || actionName === 'setLambdas') {
const items = action.payload
if (Array.isArray(items)) {
items.forEach((item: any) => {
if (item && item.id) {
persistenceQueue.enqueue({
type: 'put',
storeName: config.storeName,
key: item.id,
value: { ...item, updatedAt: Date.now() },
timestamp: Date.now(),
}, config.debounceMs)
}
})
}
}
if (actionName === 'removeItem' || actionName === 'deleteFile' || actionName === 'deleteModel' ||
actionName === 'deleteComponent' || actionName === 'deleteComponentTree' ||
actionName === 'deleteWorkflow' || actionName === 'deleteLambda') {
const itemId = typeof action.payload === 'string' ? action.payload : action.payload?.id
if (itemId) {
persistenceQueue.enqueue({
type: 'delete',
storeName: config.storeName,
key: itemId,
timestamp: Date.now(),
}, config.debounceMs)
}
}
if (actionName === 'setTheme') {
persistenceQueue.enqueue({
type: 'put',
storeName: 'theme',
key: 'current',
value: action.payload,
timestamp: Date.now(),
}, config.debounceMs)
}
if (actionName === 'updateSettings') {
persistenceQueue.enqueue({
type: 'put',
storeName: 'settings',
key: 'appSettings',
value: action.payload,
timestamp: Date.now(),
}, config.debounceMs)
}
} catch (error) {
console.error('[PersistenceMiddleware] Error handling action:', action.type, error)
}
return result
}
}
export const flushPersistence = () => persistenceQueue.flush()
export const configurePersistence = (sliceName: string, config: Partial<PersistenceConfig>) => {
if (sliceToPersistenceMap[sliceName]) {
sliceToPersistenceMap[sliceName] = {
...sliceToPersistenceMap[sliceName],
...config,
}
}
}
export const disablePersistence = (sliceName: string) => {
if (sliceToPersistenceMap[sliceName]) {
sliceToPersistenceMap[sliceName].enabled = false
}
}
export const enablePersistence = (sliceName: string) => {
if (sliceToPersistenceMap[sliceName]) {
sliceToPersistenceMap[sliceName].enabled = true
}
}

View File

@@ -0,0 +1,134 @@
import { Middleware } from '@reduxjs/toolkit'
import { RootState } from '../index'
interface SyncMetrics {
totalOperations: number
successfulOperations: number
failedOperations: number
lastOperationTime: number
averageOperationTime: number
operationTimes: number[]
}
class SyncMonitor {
private metrics: SyncMetrics = {
totalOperations: 0,
successfulOperations: 0,
failedOperations: 0,
lastOperationTime: 0,
averageOperationTime: 0,
operationTimes: [],
}
private operationStartTimes: Map<string, number> = new Map()
private listeners: Set<(metrics: SyncMetrics) => void> = new Set()
startOperation(operationId: string) {
this.operationStartTimes.set(operationId, Date.now())
}
endOperation(operationId: string, success: boolean) {
const startTime = this.operationStartTimes.get(operationId)
if (!startTime) return
const duration = Date.now() - startTime
this.operationStartTimes.delete(operationId)
this.metrics.totalOperations++
if (success) {
this.metrics.successfulOperations++
} else {
this.metrics.failedOperations++
}
this.metrics.lastOperationTime = Date.now()
this.metrics.operationTimes.push(duration)
if (this.metrics.operationTimes.length > 100) {
this.metrics.operationTimes.shift()
}
this.metrics.averageOperationTime =
this.metrics.operationTimes.reduce((a, b) => a + b, 0) / this.metrics.operationTimes.length
this.notifyListeners()
}
getMetrics(): SyncMetrics {
return { ...this.metrics }
}
subscribe(listener: (metrics: SyncMetrics) => void) {
this.listeners.add(listener)
return () => this.listeners.delete(listener)
}
private notifyListeners() {
this.listeners.forEach((listener) => listener(this.getMetrics()))
}
reset() {
this.metrics = {
totalOperations: 0,
successfulOperations: 0,
failedOperations: 0,
lastOperationTime: 0,
averageOperationTime: 0,
operationTimes: [],
}
this.notifyListeners()
}
}
export const syncMonitor = new SyncMonitor()
export const createSyncMonitorMiddleware = (): Middleware => {
return () => (next) => (action: any) => {
if (!action.type) return next(action)
const asyncThunkActions = [
'files/saveFile',
'files/deleteFile',
'models/saveModel',
'models/deleteModel',
'components/saveComponent',
'components/deleteComponent',
'componentTrees/saveComponentTree',
'componentTrees/deleteComponentTree',
'workflows/saveWorkflow',
'workflows/deleteWorkflow',
'lambdas/saveLambda',
'lambdas/deleteLambda',
'sync/syncToFlaskBulk',
'sync/syncFromFlaskBulk',
]
const isPendingAction = asyncThunkActions.some((prefix) => action.type === `${prefix}/pending`)
const isFulfilledAction = asyncThunkActions.some((prefix) => action.type === `${prefix}/fulfilled`)
const isRejectedAction = asyncThunkActions.some((prefix) => action.type === `${prefix}/rejected`)
if (isPendingAction) {
const operationId = action.meta?.requestId || `${action.type}-${Date.now()}`
syncMonitor.startOperation(operationId)
}
const result = next(action)
if (isFulfilledAction) {
const operationId = action.meta?.requestId || `${action.type}-${Date.now()}`
syncMonitor.endOperation(operationId, true)
}
if (isRejectedAction) {
const operationId = action.meta?.requestId || `${action.type}-${Date.now()}`
syncMonitor.endOperation(operationId, false)
}
return result
}
}
export const getSyncMetrics = () => syncMonitor.getMetrics()
export const resetSyncMetrics = () => syncMonitor.reset()
export const subscribeSyncMetrics = (listener: (metrics: SyncMetrics) => void) =>
syncMonitor.subscribe(listener)