Merge branch 'main' into codex/add-tests-for-persistencequeue-enqueuing

This commit is contained in:
2026-01-18 18:23:29 +00:00
committed by GitHub
8 changed files with 491 additions and 310 deletions

View File

@@ -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<ComponentNode> = 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 = <T,>(
result: string,
schema: z.ZodType<T>,
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<ComponentNode | null> {
@@ -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) {

View File

@@ -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<any>
@@ -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<string, ComponentType<any>>
): UIComponentRegistry => {
return names.reduce<UIComponentRegistry>((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<string, ComponentType<any>>
const deprecatedComponentInfo = jsonRegistryEntries.reduce<Record<string, DeprecatedComponentInfo>>(
(acc, entry) => {
const entryName = getRegistryEntryName(entry)
@@ -125,30 +55,130 @@ const deprecatedComponentInfo = jsonRegistryEntries.reduce<Record<string, Deprec
},
{}
)
const atomRegistryNames = jsonRegistryEntries
.filter((entry) => 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<string, unknown>
): Record<string, ComponentType<any>> => {
return Object.entries(exports).reduce<Record<string, ComponentType<any>>>((acc, [key, value]) => {
if (value && (typeof value === 'function' || typeof value === 'object')) {
acc[key] = value as ComponentType<any>
}
return acc
}, {})
}
const buildComponentMapFromModules = (
modules: Record<string, unknown>
): Record<string, ComponentType<any>> => {
return Object.values(modules).reduce<Record<string, ComponentType<any>>>((acc, moduleExports) => {
if (!moduleExports || typeof moduleExports !== 'object') {
return acc
}
Object.entries(buildComponentMapFromExports(moduleExports as Record<string, unknown>)).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<string, Record<string, string>> = {
atoms: {
PageHeader: 'BasicPageHeader',
SearchInput: 'BasicSearchInput',
},
molecules: {},
organisms: {},
ui: {
Chart: 'ChartContainer',
Resizable: 'ResizablePanelGroup',
},
wrappers: {},
}
const iconAliases: Record<string, string> = {
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<string, ComponentType<any>> = {
JSONUIShowcase,
}
const buildRegistryFromEntries = (
source: string,
componentMap: Record<string, ComponentType<any>>,
aliases: Record<string, string> = {}
): UIComponentRegistry => {
return jsonRegistryEntries
.filter((entry) => entry.source === source)
.reduce<UIComponentRegistry>((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<UIComponentRegistry>((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<string, ComponentType<any>> = {
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<string, ComponentType<any>>).DataList,
DataTable: (AtomComponents as Record<string, ComponentType<any>>).DataTable,
ListItem: (AtomComponents as Record<string, ComponentType<any>>).ListItem,
MetricCard: (AtomComponents as Record<string, ComponentType<any>>).MetricCard,
Timeline: (AtomComponents as Record<string, ComponentType<any>>).Timeline,
}
const breadcrumbComponent = AtomComponents.Breadcrumb ?? AtomComponents.BreadcrumbNav
if (breadcrumbComponent) {
atomComponents.Breadcrumb = breadcrumbComponent as ComponentType<any>
}
export const moleculeComponents: UIComponentRegistry = {
...buildRegistryFromNames(
moleculeRegistryNames,
MoleculeComponents as Record<string, ComponentType<any>>
),
AppBranding: (MoleculeComponents as Record<string, ComponentType<any>>).AppBranding,
LabelWithBadge: (MoleculeComponents as Record<string, ComponentType<any>>).LabelWithBadge,
NavigationGroupHeader: (MoleculeComponents as Record<string, ComponentType<any>>).NavigationGroupHeader,
}
export const organismComponents: UIComponentRegistry = buildRegistryFromNames(
organismRegistryNames,
OrganismComponents as Record<string, ComponentType<any>>
export const atomComponents: UIComponentRegistry = buildRegistryFromEntries(
'atoms',
atomComponentMap,
sourceAliases.atoms
)
const wrapperComponentMap: Record<string, ComponentType<any>> = {
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<string, ComponentType<any>> = {
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,

View File

@@ -26,30 +26,39 @@ class RateLimiter {
fn: () => Promise<T>,
priority: 'low' | 'medium' | 'high' = 'medium'
): Promise<T | null> {
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()

View File

@@ -20,6 +20,8 @@ class AutoSyncManager {
private timer: ReturnType<typeof setTimeout> | null = null
private lastSyncTime = 0
private changeCounter = 0
private inFlight = false
private pendingSync = false
private dispatch: any = null
configure(config: Partial<AutoSyncConfig>) {
@@ -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()

View File

@@ -37,6 +37,7 @@ export async function syncToFlask(
}
} catch (error) {
console.error('[FlaskSync] Error syncing to Flask:', error)
throw error
}
}

View File

@@ -38,10 +38,23 @@ type PendingOperation = {
timestamp: number
}
export class PersistenceQueue {
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<string, PendingOperation> = new Map()
private processing = false
private pendingFlush = false
private debounceTimers: Map<string, ReturnType<typeof setTimeout>> = new Map()
private failedSyncs: Map<string, FailedSyncOperation> = new Map()
private retryTimers: Map<string, ReturnType<typeof setTimeout>> = new Map()
enqueue(operation: PendingOperation, debounceMs: number) {
const opKey = `${operation.storeName}:${operation.key}`
@@ -62,7 +75,12 @@ export 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
@@ -75,14 +93,10 @@ export 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)
@@ -97,12 +111,26 @@ export class PersistenceQueue {
}
} finally {
this.processing = false
if (this.queue.size > 0) {
const needsFlush = this.pendingFlush || this.queue.size > 0
this.pendingFlush = false
if (needsFlush) {
await this.processQueue()
}
}
}
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)
@@ -110,6 +138,89 @@ export 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()
@@ -211,6 +322,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<PersistenceConfig>) => {
if (sliceToPersistenceMap[sliceName]) {

View File

@@ -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

View File

@@ -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<string>(),
models: new Set<string>(),
components: new Set<string>(),
workflows: new Set<string>(),
}
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)
}
}
}