From eb9174c80d8e62bc6e42076d206dff7b321b1d6f Mon Sep 17 00:00:00 2001 From: johndoe6345789 Date: Sun, 18 Jan 2026 18:14:51 +0000 Subject: [PATCH 1/9] Add in-flight guard to auto sync --- src/store/middleware/autoSyncMiddleware.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/store/middleware/autoSyncMiddleware.ts b/src/store/middleware/autoSyncMiddleware.ts index 277769c..b8cc1f4 100644 --- a/src/store/middleware/autoSyncMiddleware.ts +++ b/src/store/middleware/autoSyncMiddleware.ts @@ -20,6 +20,8 @@ class AutoSyncManager { private timer: ReturnType | null = null private lastSyncTime = 0 private changeCounter = 0 + private inFlight = false + private pendingSync = false private dispatch: any = null configure(config: Partial) { @@ -68,18 +70,33 @@ class AutoSyncManager { private async performSync() { if (!this.dispatch) return + if (this.inFlight) { + this.pendingSync = true + return + } + this.inFlight = true try { await this.dispatch(syncToFlaskBulk()) this.lastSyncTime = Date.now() this.changeCounter = 0 } catch (error) { console.error('[AutoSync] Sync failed:', error) + } finally { + this.inFlight = false + } + + if (this.pendingSync) { + this.pendingSync = false + await this.performSync() } } trackChange() { this.changeCounter++ + if (this.inFlight) { + this.pendingSync = true + } if (this.changeCounter >= this.config.maxQueueSize && this.config.syncOnChange) { this.performSync() From e075908a15b28ba0b74a39beba795b69d65c8cfe Mon Sep 17 00:00:00 2001 From: johndoe6345789 Date: Sun, 18 Jan 2026 18:15:25 +0000 Subject: [PATCH 2/9] Refactor rate limiter retry loop --- src/lib/rate-limiter.ts | 43 +++++++++++++++++++++++++---------------- 1 file changed, 26 insertions(+), 17 deletions(-) diff --git a/src/lib/rate-limiter.ts b/src/lib/rate-limiter.ts index cd54531..3f17ebe 100644 --- a/src/lib/rate-limiter.ts +++ b/src/lib/rate-limiter.ts @@ -26,30 +26,39 @@ class RateLimiter { fn: () => Promise, priority: 'low' | 'medium' | 'high' = 'medium' ): Promise { - const now = Date.now() - const record = this.requests.get(key) + const maxHighPriorityRetries = 5 + let retryCount = 0 + let record: RequestRecord | undefined - if (record) { - const timeElapsed = now - record.timestamp + while (true) { + const now = Date.now() + record = this.requests.get(key) - if (timeElapsed < this.config.windowMs) { - if (record.count >= this.config.maxRequests) { - console.warn(`Rate limit exceeded for ${key}. Try again in ${Math.ceil((this.config.windowMs - timeElapsed) / 1000)}s`) - - if (priority === 'high') { - await new Promise(resolve => setTimeout(resolve, this.config.retryDelay)) - return this.throttle(key, fn, priority) + if (record) { + const timeElapsed = now - record.timestamp + + if (timeElapsed < this.config.windowMs) { + if (record.count >= this.config.maxRequests) { + console.warn(`Rate limit exceeded for ${key}. Try again in ${Math.ceil((this.config.windowMs - timeElapsed) / 1000)}s`) + + if (priority === 'high' && retryCount < maxHighPriorityRetries) { + retryCount++ + await new Promise(resolve => setTimeout(resolve, this.config.retryDelay)) + continue + } + + return null } - - return null + + record.count++ + } else { + this.requests.set(key, { timestamp: now, count: 1 }) } - - record.count++ } else { this.requests.set(key, { timestamp: now, count: 1 }) } - } else { - this.requests.set(key, { timestamp: now, count: 1 }) + + break } this.cleanup() From baf5001704072e0e5f7f9f4b0804046539410baa Mon Sep 17 00:00:00 2001 From: johndoe6345789 Date: Sun, 18 Jan 2026 18:15:46 +0000 Subject: [PATCH 3/9] Refactor JSON UI component registry --- src/lib/json-ui/component-registry.ts | 408 ++++++++++---------------- 1 file changed, 150 insertions(+), 258 deletions(-) diff --git a/src/lib/json-ui/component-registry.ts b/src/lib/json-ui/component-registry.ts index 736e039..371c40d 100644 --- a/src/lib/json-ui/component-registry.ts +++ b/src/lib/json-ui/component-registry.ts @@ -1,63 +1,7 @@ import { ComponentType } from 'react' -import { Button } from '@/components/ui/button' -import { Input } from '@/components/ui/input' -import { InputOtp } from '@/components/ui/input-otp' -import { Textarea } from '@/components/ui/textarea' -import { Label } from '@/components/ui/label' -import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from '@/components/ui/card' -import { Badge } from '@/components/ui/badge' -import { Separator } from '@/components/ui/separator' -import { Alert as ShadcnAlert, AlertDescription, AlertTitle } from '@/components/ui/alert' -import { AlertDialog } from '@/components/ui/alert-dialog' -import { AspectRatio } from '@/components/ui/aspect-ratio' -import { Carousel } from '@/components/ui/carousel' -import { ChartContainer as Chart } from '@/components/ui/chart' -import { Collapsible } from '@/components/ui/collapsible' -import { Command } from '@/components/ui/command' -import { Switch } from '@/components/ui/switch' -import { Checkbox } from '@/components/ui/checkbox' -import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group' -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' -import { DropdownMenu } from '@/components/ui/dropdown-menu' -import { Menubar } from '@/components/ui/menubar' -import { NavigationMenu } from '@/components/ui/navigation-menu' -import { Table as ShadcnTable, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table' -import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' -import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog' -import { Skeleton as ShadcnSkeleton } from '@/components/ui/skeleton' -import { Progress } from '@/components/ui/progress' -import { Pagination } from '@/components/ui/pagination' -import { ResizablePanelGroup as Resizable } from '@/components/ui/resizable' -import { Sheet } from '@/components/ui/sheet' -import { Sidebar } from '@/components/ui/sidebar' -import { Toaster as Sonner } from '@/components/ui/sonner' -import { ToggleGroup } from '@/components/ui/toggle-group' -import { Avatar as ShadcnAvatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' -import { CircularProgress, Divider, ProgressBar } from '@/components/atoms' -import * as AtomComponents from '@/components/atoms' -import * as MoleculeComponents from '@/components/molecules' -import * as OrganismComponents from '@/components/organisms' -import { - ComponentBindingDialogWrapper, - ComponentTreeWrapper, - DataSourceEditorDialogWrapper, - GitHubBuildStatusWrapper, - LazyBarChartWrapper, - LazyD3BarChartWrapper, - LazyLineChartWrapper, - SaveIndicatorWrapper, - SeedDataManagerWrapper, - StorageSettingsWrapper, -} from '@/lib/json-ui/wrappers' +import * as PhosphorIcons from '@phosphor-icons/react' import jsonComponentsRegistry from '../../../json-components-registry.json' -import { - ArrowLeft, ArrowRight, Check, X, Plus, Minus, MagnifyingGlass, - Funnel, Download, Upload, PencilSimple, Trash, Eye, EyeClosed, - CaretUp, CaretDown, CaretLeft, CaretRight, - Gear, User, Bell, Envelope, Calendar, Clock, Star, - Heart, ShareNetwork, LinkSimple, Copy, FloppyDisk, ArrowClockwise, WarningCircle, - Info, Question, House, List as ListIcon, DotsThreeVertical, DotsThree -} from '@phosphor-icons/react' +import { JSONUIShowcase } from '@/components/JSONUIShowcase' export interface UIComponentRegistry { [key: string]: ComponentType @@ -89,19 +33,6 @@ const jsonRegistry = jsonComponentsRegistry as JsonComponentRegistry const getRegistryEntryName = (entry: JsonRegistryEntry): string | undefined => entry.export ?? entry.name ?? entry.type -const buildRegistryFromNames = ( - names: string[], - components: Record> -): UIComponentRegistry => { - return names.reduce((registry, name) => { - const component = components[name] - if (component) { - registry[name] = component - } - return registry - }, {}) -} - const jsonRegistryEntries = jsonRegistry.components ?? [] const registryEntryByType = new Map( jsonRegistryEntries @@ -111,7 +42,6 @@ const registryEntryByType = new Map( }) .filter((entry): entry is [string, JsonRegistryEntry] => Boolean(entry)) ) -const atomComponentMap = AtomComponents as Record> const deprecatedComponentInfo = jsonRegistryEntries.reduce>( (acc, entry) => { const entryName = getRegistryEntryName(entry) @@ -125,30 +55,130 @@ const deprecatedComponentInfo = jsonRegistryEntries.reduce entry.source === 'atoms') - .map((entry) => getRegistryEntryName(entry)) - .filter((name): name is string => Boolean(name)) -const moleculeRegistryNames = jsonRegistryEntries - .filter((entry) => entry.source === 'molecules') - .map((entry) => getRegistryEntryName(entry)) - .filter((name): name is string => Boolean(name)) -const organismRegistryNames = jsonRegistryEntries - .filter((entry) => entry.source === 'organisms') - .map((entry) => getRegistryEntryName(entry)) - .filter((name): name is string => Boolean(name)) -const shadcnRegistryNames = jsonRegistryEntries - .filter((entry) => entry.source === 'ui') - .map((entry) => getRegistryEntryName(entry)) - .filter((name): name is string => Boolean(name)) -const wrapperRegistryNames = jsonRegistryEntries - .filter((entry) => entry.source === 'wrappers') - .map((entry) => getRegistryEntryName(entry)) - .filter((name): name is string => Boolean(name)) -const iconRegistryNames = jsonRegistryEntries - .filter((entry) => entry.source === 'icons') - .map((entry) => getRegistryEntryName(entry)) - .filter((name): name is string => Boolean(name)) + +const buildComponentMapFromExports = ( + exports: Record +): Record> => { + return Object.entries(exports).reduce>>((acc, [key, value]) => { + if (value && (typeof value === 'function' || typeof value === 'object')) { + acc[key] = value as ComponentType + } + return acc + }, {}) +} + +const buildComponentMapFromModules = ( + modules: Record +): Record> => { + return Object.values(modules).reduce>>((acc, moduleExports) => { + if (!moduleExports || typeof moduleExports !== 'object') { + return acc + } + Object.entries(buildComponentMapFromExports(moduleExports as Record)).forEach( + ([key, component]) => { + acc[key] = component + } + ) + return acc + }, {}) +} + +const atomModules = import.meta.glob('@/components/atoms/*.tsx', { eager: true }) +const moleculeModules = import.meta.glob('@/components/molecules/*.tsx', { eager: true }) +const organismModules = import.meta.glob('@/components/organisms/*.tsx', { eager: true }) +const uiModules = import.meta.glob('@/components/ui/**/*.{ts,tsx}', { eager: true }) +const wrapperModules = import.meta.glob('@/lib/json-ui/wrappers/*.tsx', { eager: true }) + +const atomComponentMap = buildComponentMapFromModules(atomModules) +const moleculeComponentMap = buildComponentMapFromModules(moleculeModules) +const organismComponentMap = buildComponentMapFromModules(organismModules) +const uiComponentMap = buildComponentMapFromModules(uiModules) +const wrapperComponentMap = buildComponentMapFromModules(wrapperModules) +const iconComponentMap = buildComponentMapFromExports(PhosphorIcons) + +const sourceAliases: Record> = { + atoms: { + PageHeader: 'BasicPageHeader', + SearchInput: 'BasicSearchInput', + }, + molecules: {}, + organisms: {}, + ui: { + Chart: 'ChartContainer', + Resizable: 'ResizablePanelGroup', + }, + wrappers: {}, +} + +const iconAliases: Record = { + Search: 'MagnifyingGlass', + Filter: 'Funnel', + Edit: 'PencilSimple', + EyeOff: 'EyeClosed', + ChevronUp: 'CaretUp', + ChevronDown: 'CaretDown', + ChevronLeft: 'CaretLeft', + ChevronRight: 'CaretRight', + Settings: 'Gear', + Mail: 'Envelope', + Share: 'ShareNetwork', + Link: 'LinkSimple', + Save: 'FloppyDisk', + RefreshCw: 'ArrowClockwise', + AlertCircle: 'WarningCircle', + HelpCircle: 'Question', + Home: 'House', + Menu: 'List', + MoreVertical: 'DotsThreeVertical', + MoreHorizontal: 'DotsThree', +} + +const explicitComponentAllowlist: Record> = { + JSONUIShowcase, +} + +const buildRegistryFromEntries = ( + source: string, + componentMap: Record>, + aliases: Record = {} +): UIComponentRegistry => { + return jsonRegistryEntries + .filter((entry) => entry.source === source) + .reduce((registry, entry) => { + const entryName = getRegistryEntryName(entry) + if (!entryName) { + return registry + } + const aliasName = aliases[entryName] + const component = + componentMap[entryName] ?? + (aliasName ? componentMap[aliasName] : undefined) ?? + explicitComponentAllowlist[entryName] + if (component) { + registry[entryName] = component + } + return registry + }, {}) +} + +const buildIconRegistry = (): UIComponentRegistry => { + return jsonRegistryEntries + .filter((entry) => entry.source === 'icons') + .reduce((registry, entry) => { + const entryName = getRegistryEntryName(entry) + if (!entryName) { + return registry + } + const aliasName = iconAliases[entryName] + const component = + iconComponentMap[entryName] ?? + (aliasName ? iconComponentMap[aliasName] : undefined) + if (component) { + registry[entryName] = component + } + return registry + }, {}) +} export const primitiveComponents: UIComponentRegistry = { div: 'div' as any, @@ -169,176 +199,38 @@ export const primitiveComponents: UIComponentRegistry = { nav: 'nav' as any, } -const shadcnComponentMap: Record> = { - AlertDialog, - AspectRatio, - Button, - Carousel, - Chart, - Collapsible, - Command, - DropdownMenu, - Input, - InputOtp, - Textarea, - Label, - Card, - CardHeader, - CardTitle, - CardDescription, - CardContent, - CardFooter, - Badge, - Separator, - Alert: ShadcnAlert, - AlertDescription, - AlertTitle, - Switch, - Checkbox, - RadioGroup, - RadioGroupItem, - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, - Table: ShadcnTable, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, - Tabs, - TabsContent, - TabsList, - TabsTrigger, - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, - Menubar, - NavigationMenu, - Skeleton: ShadcnSkeleton, - Pagination, - Progress, - Resizable, - Sheet, - Sidebar, - Sonner, - ToggleGroup, - Avatar: ShadcnAvatar, - AvatarFallback, - AvatarImage, -} - -export const shadcnComponents: UIComponentRegistry = buildRegistryFromNames( - shadcnRegistryNames, - shadcnComponentMap +export const shadcnComponents: UIComponentRegistry = buildRegistryFromEntries( + 'ui', + uiComponentMap, + sourceAliases.ui ) -export const atomComponents: UIComponentRegistry = { - ...buildRegistryFromNames( - atomRegistryNames, - atomComponentMap - ), - DatePicker: atomComponentMap.DatePicker, - FileUpload: atomComponentMap.FileUpload, - CircularProgress, - Divider, - ProgressBar, - DataList: (AtomComponents as Record>).DataList, - DataTable: (AtomComponents as Record>).DataTable, - ListItem: (AtomComponents as Record>).ListItem, - MetricCard: (AtomComponents as Record>).MetricCard, - Timeline: (AtomComponents as Record>).Timeline, -} - -const breadcrumbComponent = AtomComponents.Breadcrumb ?? AtomComponents.BreadcrumbNav -if (breadcrumbComponent) { - atomComponents.Breadcrumb = breadcrumbComponent as ComponentType -} - -export const moleculeComponents: UIComponentRegistry = { - ...buildRegistryFromNames( - moleculeRegistryNames, - MoleculeComponents as Record> - ), - AppBranding: (MoleculeComponents as Record>).AppBranding, - LabelWithBadge: (MoleculeComponents as Record>).LabelWithBadge, - NavigationGroupHeader: (MoleculeComponents as Record>).NavigationGroupHeader, -} - -export const organismComponents: UIComponentRegistry = buildRegistryFromNames( - organismRegistryNames, - OrganismComponents as Record> +export const atomComponents: UIComponentRegistry = buildRegistryFromEntries( + 'atoms', + atomComponentMap, + sourceAliases.atoms ) -const wrapperComponentMap: Record> = { - ComponentBindingDialogWrapper, - ComponentTreeWrapper, - DataSourceEditorDialogWrapper, - GitHubBuildStatusWrapper, - SaveIndicatorWrapper, - LazyBarChartWrapper, - LazyLineChartWrapper, - LazyD3BarChartWrapper, - SeedDataManagerWrapper, - StorageSettingsWrapper, -} - -export const jsonWrapperComponents: UIComponentRegistry = buildRegistryFromNames( - wrapperRegistryNames, - wrapperComponentMap +export const moleculeComponents: UIComponentRegistry = buildRegistryFromEntries( + 'molecules', + moleculeComponentMap, + sourceAliases.molecules ) -const iconComponentMap: Record> = { - ArrowLeft, - ArrowRight, - Check, - X, - Plus, - Minus, - Search: MagnifyingGlass, - Filter: Funnel, - Download, - Upload, - Edit: PencilSimple, - Trash, - Eye, - EyeOff: EyeClosed, - ChevronUp: CaretUp, - ChevronDown: CaretDown, - ChevronLeft: CaretLeft, - ChevronRight: CaretRight, - Settings: Gear, - User, - Bell, - Mail: Envelope, - Calendar, - Clock, - Star, - Heart, - Share: ShareNetwork, - Link: LinkSimple, - Copy, - Save: FloppyDisk, - RefreshCw: ArrowClockwise, - AlertCircle: WarningCircle, - Info, - HelpCircle: Question, - Home: House, - Menu: ListIcon, - MoreVertical: DotsThreeVertical, - MoreHorizontal: DotsThree, -} - -export const iconComponents: UIComponentRegistry = buildRegistryFromNames( - iconRegistryNames, - iconComponentMap +export const organismComponents: UIComponentRegistry = buildRegistryFromEntries( + 'organisms', + organismComponentMap, + sourceAliases.organisms ) +export const jsonWrapperComponents: UIComponentRegistry = buildRegistryFromEntries( + 'wrappers', + wrapperComponentMap, + sourceAliases.wrappers +) + +export const iconComponents: UIComponentRegistry = buildIconRegistry() + export const uiComponentRegistry: UIComponentRegistry = { ...primitiveComponents, ...shadcnComponents, From 0a0046c2f31020adb667702b014c6e686c49538f Mon Sep 17 00:00:00 2001 From: johndoe6345789 Date: Sun, 18 Jan 2026 18:18:58 +0000 Subject: [PATCH 4/9] Fix persistence queue flush ordering --- src/store/middleware/persistenceMiddleware.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/store/middleware/persistenceMiddleware.ts b/src/store/middleware/persistenceMiddleware.ts index 7d20c2e..355ebca 100644 --- a/src/store/middleware/persistenceMiddleware.ts +++ b/src/store/middleware/persistenceMiddleware.ts @@ -41,6 +41,7 @@ type PendingOperation = { class PersistenceQueue { private queue: Map = new Map() private processing = false + private pendingFlush = false private debounceTimers: Map> = new Map() enqueue(operation: PendingOperation, debounceMs: number) { @@ -62,7 +63,12 @@ class PersistenceQueue { } async processQueue() { - if (this.processing || this.queue.size === 0) return + if (this.processing) { + this.pendingFlush = true + return + } + + if (this.queue.size === 0) return this.processing = true @@ -97,6 +103,11 @@ class PersistenceQueue { } } finally { this.processing = false + const needsFlush = this.pendingFlush || this.queue.size > 0 + this.pendingFlush = false + if (needsFlush) { + await this.processQueue() + } } } From def3259178bceb9662d6e2cbe934499c0d7696f4 Mon Sep 17 00:00:00 2001 From: johndoe6345789 Date: Sun, 18 Jan 2026 18:20:22 +0000 Subject: [PATCH 5/9] Handle Flask sync failures with retries --- src/store/middleware/flaskSync.ts | 1 + src/store/middleware/persistenceMiddleware.ts | 117 +++++++++++++++++- 2 files changed, 112 insertions(+), 6 deletions(-) diff --git a/src/store/middleware/flaskSync.ts b/src/store/middleware/flaskSync.ts index 99a705d..c7c7cbc 100644 --- a/src/store/middleware/flaskSync.ts +++ b/src/store/middleware/flaskSync.ts @@ -37,6 +37,7 @@ export async function syncToFlask( } } catch (error) { console.error('[FlaskSync] Error syncing to Flask:', error) + throw error } } diff --git a/src/store/middleware/persistenceMiddleware.ts b/src/store/middleware/persistenceMiddleware.ts index 7d20c2e..e429ca1 100644 --- a/src/store/middleware/persistenceMiddleware.ts +++ b/src/store/middleware/persistenceMiddleware.ts @@ -38,10 +38,22 @@ type PendingOperation = { timestamp: number } +type FailedSyncOperation = PendingOperation & { + attempt: number + lastError: string + nextRetryAt: number +} + +const MAX_SYNC_RETRIES = 5 +const BASE_SYNC_RETRY_DELAY_MS = 1000 +const MAX_SYNC_RETRY_DELAY_MS = 30000 + class PersistenceQueue { private queue: Map = new Map() private processing = false private debounceTimers: Map> = new Map() + private failedSyncs: Map = new Map() + private retryTimers: Map> = new Map() enqueue(operation: PendingOperation, debounceMs: number) { const opKey = `${operation.storeName}:${operation.key}` @@ -75,14 +87,10 @@ class PersistenceQueue { 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') - } + await this.syncToFlaskWithRetry(op, op.value) } 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') - } + await this.syncToFlaskWithRetry(op, null) } } catch (error) { console.error(`[PersistenceMiddleware] Failed to persist ${op.type} for ${op.storeName}:${op.key}`, error) @@ -100,6 +108,18 @@ class PersistenceQueue { } } + getFailedSyncs() { + return Array.from(this.failedSyncs.values()).sort((a, b) => a.nextRetryAt - b.nextRetryAt) + } + + async retryFailedSyncs() { + for (const [opKey, failure] of this.failedSyncs.entries()) { + if (failure.nextRetryAt <= Date.now()) { + await this.retryFailedSync(opKey) + } + } + } + async flush() { for (const timer of this.debounceTimers.values()) { clearTimeout(timer) @@ -107,6 +127,89 @@ class PersistenceQueue { this.debounceTimers.clear() await this.processQueue() } + + private async syncToFlaskWithRetry(op: PendingOperation, value: any) { + if (!sliceToPersistenceMap[op.storeName]?.syncToFlask) return + + try { + await syncToFlask(op.storeName, op.key, value, op.type) + this.clearSyncFailure(op) + } catch (error) { + this.recordSyncFailure(op, error) + console.warn( + `[PersistenceMiddleware] Flask sync failed for ${op.storeName}:${op.key} (${op.type}); queued for retry.`, + error + ) + } + } + + private recordSyncFailure(op: PendingOperation, error: unknown) { + const opKey = this.getFailureKey(op) + const previous = this.failedSyncs.get(opKey) + const attempt = previous ? previous.attempt + 1 : 1 + const delayMs = this.getRetryDelayMs(attempt) + const nextRetryAt = Date.now() + delayMs + const lastError = error instanceof Error ? error.message : String(error) + + this.failedSyncs.set(opKey, { + ...op, + attempt, + lastError, + nextRetryAt, + }) + + const existingTimer = this.retryTimers.get(opKey) + if (existingTimer) { + clearTimeout(existingTimer) + } + + if (attempt <= MAX_SYNC_RETRIES) { + const timer = setTimeout(() => { + this.retryTimers.delete(opKey) + void this.retryFailedSync(opKey) + }, delayMs) + this.retryTimers.set(opKey, timer) + } + } + + private clearSyncFailure(op: PendingOperation) { + const opKey = this.getFailureKey(op) + const timer = this.retryTimers.get(opKey) + if (timer) { + clearTimeout(timer) + this.retryTimers.delete(opKey) + } + this.failedSyncs.delete(opKey) + } + + private async retryFailedSync(opKey: string) { + const failure = this.failedSyncs.get(opKey) + if (!failure) return + + if (failure.attempt > MAX_SYNC_RETRIES) { + return + } + + this.enqueue( + { + type: failure.type, + storeName: failure.storeName, + key: failure.key, + value: failure.value, + timestamp: Date.now(), + }, + 0 + ) + } + + private getRetryDelayMs(attempt: number) { + const delay = BASE_SYNC_RETRY_DELAY_MS * Math.pow(2, attempt - 1) + return Math.min(delay, MAX_SYNC_RETRY_DELAY_MS) + } + + private getFailureKey(op: PendingOperation) { + return `${op.storeName}:${op.key}:${op.type}` + } } const persistenceQueue = new PersistenceQueue() @@ -208,6 +311,8 @@ export const createPersistenceMiddleware = (): Middleware => { } export const flushPersistence = () => persistenceQueue.flush() +export const getFailedSyncOperations = () => persistenceQueue.getFailedSyncs() +export const retryFailedSyncOperations = () => persistenceQueue.retryFailedSyncs() export const configurePersistence = (sliceName: string, config: Partial) => { if (sliceToPersistenceMap[sliceName]) { From 714fb510abbda451d378bc61899b2ce7732b17c6 Mon Sep 17 00:00:00 2001 From: johndoe6345789 Date: Sun, 18 Jan 2026 18:20:53 +0000 Subject: [PATCH 6/9] Validate sync keys and reconcile local data --- src/store/slices/syncSlice.ts | 48 +++++++++++++++++++++++++++++------ 1 file changed, 40 insertions(+), 8 deletions(-) diff --git a/src/store/slices/syncSlice.ts b/src/store/slices/syncSlice.ts index 046bef8..7baaa21 100644 --- a/src/store/slices/syncSlice.ts +++ b/src/store/slices/syncSlice.ts @@ -68,15 +68,47 @@ export const syncFromFlaskBulk = createAsyncThunk( async (_, { rejectWithValue }) => { try { const data = await fetchAllFromFlask() - + const allowedStoreNames = new Set(['files', 'models', 'components', 'workflows']) + const serverIdsByStore = { + files: new Set(), + models: new Set(), + components: new Set(), + workflows: new Set(), + } + for (const [key, value] of Object.entries(data)) { - const [storeName, id] = key.split(':') - - if (storeName === 'files' || - storeName === 'models' || - storeName === 'components' || - storeName === 'workflows') { - await db.put(storeName as any, value) + if (typeof key !== 'string') { + continue + } + + const parts = key.split(':') + if (parts.length !== 2) { + continue + } + + const [storeName, id] = parts + if (!storeName || !id) { + continue + } + + if (!allowedStoreNames.has(storeName)) { + continue + } + + serverIdsByStore[storeName as keyof typeof serverIdsByStore].add(id) + await db.put(storeName as any, value) + } + + // Explicit merge strategy: server is source of truth; delete local records missing from server response. + const storeNames = Array.from(allowedStoreNames) + for (const storeName of storeNames) { + const localRecords = await db.getAll(storeName as any) + for (const record of localRecords) { + const recordId = record?.id + const recordIdString = recordId == null ? '' : String(recordId) + if (!serverIdsByStore[storeName as keyof typeof serverIdsByStore].has(recordIdString)) { + await db.delete(storeName as any, recordId) + } } } From ace40f7e7338ef1352e967fc10dbe3ca4e442d7a Mon Sep 17 00:00:00 2001 From: johndoe6345789 Date: Sun, 18 Jan 2026 18:21:25 +0000 Subject: [PATCH 7/9] Add runtime validation for AI responses --- src/lib/ai-service.ts | 138 +++++++++++++++++++++++++++++++++++++++--- 1 file changed, 129 insertions(+), 9 deletions(-) diff --git a/src/lib/ai-service.ts b/src/lib/ai-service.ts index ab667d3..d87219b 100644 --- a/src/lib/ai-service.ts +++ b/src/lib/ai-service.ts @@ -2,6 +2,101 @@ import { PrismaModel, ComponentNode, ThemeConfig, ProjectFile } from '@/types/project' import { ProtectedLLMService } from './protected-llm-service' +import { toast } from 'sonner' +import { z } from 'zod' + +const componentNodeSchema: z.ZodType = z.lazy(() => z.object({ + id: z.string(), + type: z.string(), + name: z.string(), + props: z.record(z.any()), + children: z.array(componentNodeSchema) +})) + +const prismaFieldSchema = z.object({ + id: z.string(), + name: z.string(), + type: z.string(), + isRequired: z.boolean(), + isUnique: z.boolean(), + isArray: z.boolean(), + defaultValue: z.string().optional(), + relation: z.string().optional() +}) + +const prismaModelSchema = z.object({ + id: z.string(), + name: z.string(), + fields: z.array(prismaFieldSchema) +}) + +const themeSchema = z.object({ + primaryColor: z.string(), + secondaryColor: z.string(), + errorColor: z.string(), + warningColor: z.string(), + successColor: z.string(), + fontFamily: z.string(), + fontSize: z.object({ + small: z.number(), + medium: z.number(), + large: z.number() + }), + spacing: z.number(), + borderRadius: z.number() +}) + +const projectFileSchema = z.object({ + id: z.string(), + name: z.string(), + path: z.string(), + content: z.string(), + language: z.string() +}) + +const componentResponseSchema = z.object({ component: componentNodeSchema }) +const prismaModelResponseSchema = z.object({ model: prismaModelSchema }) +const themeResponseSchema = z.object({ theme: themeSchema }) +const suggestFieldsResponseSchema = z.object({ fields: z.array(z.string()) }) +const completeAppResponseSchema = z.object({ + files: z.array(projectFileSchema), + models: z.array(prismaModelSchema), + theme: themeSchema +}) + +const parseAndValidateJson = ( + result: string, + schema: z.ZodType, + context: string, + toastMessage: string +): T | null => { + let parsed: unknown + + try { + parsed = JSON.parse(result) + } catch (error) { + console.error('AI response JSON parse failed', { + context, + error: error instanceof Error ? error.message : String(error), + rawResponse: result + }) + toast.error(toastMessage) + return null + } + + const validation = schema.safeParse(parsed) + if (!validation.success) { + console.error('AI response validation failed', { + context, + issues: validation.error.issues, + rawResponse: parsed + }) + toast.error(toastMessage) + return null + } + + return validation.data +} export class AIService { static async generateComponent(description: string): Promise { @@ -29,8 +124,13 @@ Make sure to use appropriate Material UI components and props. Keep the structur ) if (result) { - const parsed = JSON.parse(result) - return parsed.component + const parsed = parseAndValidateJson( + result, + componentResponseSchema, + 'generate-component', + 'AI component response was invalid. Please retry or clarify your description.' + ) + return parsed ? parsed.component : null } return null } catch (error) { @@ -80,8 +180,13 @@ Return a valid JSON object with a single property "model" containing the model s ) if (result) { - const parsed = JSON.parse(result) - return parsed.model + const parsed = parseAndValidateJson( + result, + prismaModelResponseSchema, + 'generate-model', + 'AI model response was invalid. Please retry or describe the model differently.' + ) + return parsed ? parsed.model : null } return null } catch (error) { @@ -172,8 +277,13 @@ Return a valid JSON object with a single property "theme" containing: ) if (result) { - const parsed = JSON.parse(result) - return parsed.theme + const parsed = parseAndValidateJson( + result, + themeResponseSchema, + 'generate-theme', + 'AI theme response was invalid. Please retry or specify the theme requirements.' + ) + return parsed ? parsed.theme : null } return null } catch (error) { @@ -202,8 +312,13 @@ Suggest 3-5 common fields that would be useful for this model type. Use camelCas ) if (result) { - const parsed = JSON.parse(result) - return parsed.fields + const parsed = parseAndValidateJson( + result, + suggestFieldsResponseSchema, + 'suggest-fields', + 'AI field suggestions were invalid. Please retry with a clearer model name.' + ) + return parsed ? parsed.fields : null } return null } catch (error) { @@ -284,7 +399,12 @@ Create 2-4 essential files for the app structure. Include appropriate Prisma mod ) if (result) { - return JSON.parse(result) + return parseAndValidateJson( + result, + completeAppResponseSchema, + 'generate-app', + 'AI app generation response was invalid. Please retry with more detail.' + ) } return null } catch (error) { From 7d75c6adc0270b9ffff50ca756292f65ad95597d Mon Sep 17 00:00:00 2001 From: johndoe6345789 Date: Sun, 18 Jan 2026 18:22:00 +0000 Subject: [PATCH 8/9] Guard sync monitor tracking by requestId --- src/store/middleware/syncMonitorMiddleware.ts | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/src/store/middleware/syncMonitorMiddleware.ts b/src/store/middleware/syncMonitorMiddleware.ts index 2ddfa92..df97aa0 100644 --- a/src/store/middleware/syncMonitorMiddleware.ts +++ b/src/store/middleware/syncMonitorMiddleware.ts @@ -107,21 +107,18 @@ export const createSyncMonitorMiddleware = (): Middleware => { 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) + if (isPendingAction && action.meta?.requestId) { + syncMonitor.startOperation(action.meta.requestId) } const result = next(action) - if (isFulfilledAction) { - const operationId = action.meta?.requestId || `${action.type}-${Date.now()}` - syncMonitor.endOperation(operationId, true) + if (isFulfilledAction && action.meta?.requestId) { + syncMonitor.endOperation(action.meta.requestId, true) } - if (isRejectedAction) { - const operationId = action.meta?.requestId || `${action.type}-${Date.now()}` - syncMonitor.endOperation(operationId, false) + if (isRejectedAction && action.meta?.requestId) { + syncMonitor.endOperation(action.meta.requestId, false) } return result From e098b9184bd1b70fd25f3f08d7e146c5ec18fe39 Mon Sep 17 00:00:00 2001 From: johndoe6345789 Date: Sun, 18 Jan 2026 18:22:34 +0000 Subject: [PATCH 9/9] Add PersistenceQueue mid-flight flush test --- .../middleware/persistenceMiddleware.test.ts | 103 ++++++++++++++++++ src/store/middleware/persistenceMiddleware.ts | 5 +- 2 files changed, 107 insertions(+), 1 deletion(-) create mode 100644 src/store/middleware/persistenceMiddleware.test.ts diff --git a/src/store/middleware/persistenceMiddleware.test.ts b/src/store/middleware/persistenceMiddleware.test.ts new file mode 100644 index 0000000..3e91cd6 --- /dev/null +++ b/src/store/middleware/persistenceMiddleware.test.ts @@ -0,0 +1,103 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { PersistenceQueue } from './persistenceMiddleware' + +const { putMock, deleteMock, syncMock } = vi.hoisted(() => ({ + putMock: vi.fn<[string, unknown], Promise>(), + deleteMock: vi.fn<[string, string], Promise>(), + syncMock: vi.fn<[string, string, unknown, string], Promise>() +})) + +vi.mock('@/lib/db', () => ({ + db: { + put: putMock, + delete: deleteMock + } +})) + +vi.mock('./flaskSync', () => ({ + syncToFlask: syncMock +})) + +const nextTick = () => new Promise(resolve => setTimeout(resolve, 0)) + +const waitFor = async (assertion: () => void, attempts = 5) => { + let lastError: unknown + + for (let i = 0; i < attempts; i += 1) { + await nextTick() + + try { + assertion() + return + } catch (error) { + lastError = error + } + } + + throw lastError +} + +const createControlledPromise = () => { + let resolve: () => void + + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise + }) + + return { + promise, + resolve: resolve! + } +} + +describe('PersistenceQueue', () => { + beforeEach(() => { + putMock.mockReset() + deleteMock.mockReset() + syncMock.mockReset() + syncMock.mockResolvedValue(undefined) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('flushes new operations enqueued while processing after the first batch finishes', async () => { + const queue = new PersistenceQueue() + const controlled = createControlledPromise() + + putMock + .mockReturnValueOnce(controlled.promise) + .mockResolvedValueOnce(undefined) + + queue.enqueue({ + type: 'put', + storeName: 'files', + key: 'file-1', + value: { id: 'file-1' }, + timestamp: Date.now(), + }, 0) + + await waitFor(() => { + expect(putMock).toHaveBeenCalledTimes(1) + }) + + queue.enqueue({ + type: 'put', + storeName: 'files', + key: 'file-2', + value: { id: 'file-2' }, + timestamp: Date.now(), + }, 0) + + await nextTick() + expect(putMock).toHaveBeenCalledTimes(1) + + controlled.resolve() + + await waitFor(() => { + expect(putMock).toHaveBeenCalledTimes(2) + }) + }) +}) diff --git a/src/store/middleware/persistenceMiddleware.ts b/src/store/middleware/persistenceMiddleware.ts index 7d20c2e..0039383 100644 --- a/src/store/middleware/persistenceMiddleware.ts +++ b/src/store/middleware/persistenceMiddleware.ts @@ -38,7 +38,7 @@ type PendingOperation = { timestamp: number } -class PersistenceQueue { +export class PersistenceQueue { private queue: Map = new Map() private processing = false private debounceTimers: Map> = new Map() @@ -97,6 +97,9 @@ class PersistenceQueue { } } finally { this.processing = false + if (this.queue.size > 0) { + await this.processQueue() + } } }